diff options
Diffstat (limited to 'frontend/src/lib/components/ui/chart/chart-tooltip.svelte')
| -rw-r--r-- | frontend/src/lib/components/ui/chart/chart-tooltip.svelte | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/frontend/src/lib/components/ui/chart/chart-tooltip.svelte b/frontend/src/lib/components/ui/chart/chart-tooltip.svelte new file mode 100644 index 0000000..8811436 --- /dev/null +++ b/frontend/src/lib/components/ui/chart/chart-tooltip.svelte @@ -0,0 +1,180 @@ +<script lang="ts"> + import { getChartContext, Tooltip as TooltipPrimitive } from "layerchart"; + import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js"; + import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from "./chart-utils.js"; + import type { Snippet } from "svelte"; + import type { HTMLAttributes } from "svelte/elements"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function defaultFormatter(value: any, _payload: TooltipPayload[]) { + return `${value}`; + } + + let { + ref = $bindable(null), + class: className, + hideLabel = false, + indicator = "dot", + hideIndicator = false, + labelKey, + label, + labelFormatter = defaultFormatter, + labelClassName, + formatter, + nameKey, + color, + ...restProps + }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & { + hideLabel?: boolean; + label?: string; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; + hideIndicator?: boolean; + labelClassName?: string; + labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null; + formatter?: Snippet< + [ + { + value: unknown; + name: string; + item: TooltipPayload; + index: number; + payload: TooltipPayload[]; + }, + ] + >; + } = $props(); + + const chart = useChart(); + const chartCtx = getChartContext(); + + // Filter to series with defined values (important for item-based charts like Pie/Arc + // where only the hovered item has a value) + const visibleSeries = $derived( + chartCtx.tooltip.series.filter((s: TooltipPayload) => s.value !== undefined) + ); + + const formattedLabel = $derived.by(() => { + if (hideLabel || !visibleSeries?.length) return null; + + const [item] = visibleSeries; + const tooltipData = chartCtx.tooltip.data; + + // Get the x-axis label value from the raw tooltip data (e.g. a Date or month string) + const dataLabel = tooltipData != null ? chartCtx.x(tooltipData) : undefined; + + const key = labelKey ?? item?.label ?? item?.key ?? "value"; + const itemConfig = getPayloadConfigFromPayload( + chart.config, + item, + key, + tooltipData as Record<string, unknown> | null + ); + + let value: unknown; + if (!labelKey && typeof label === "string") { + value = chart.config[label as keyof typeof chart.config]?.label ?? label; + } else if (labelKey) { + value = itemConfig?.label ?? dataLabel; + } else { + value = dataLabel; + } + + if (value === undefined) return null; + if (!labelFormatter) return value; + return labelFormatter(value, visibleSeries); + }); + + const nestLabel = $derived(visibleSeries.length === 1 && indicator !== "dot"); +</script> + +{#snippet TooltipLabel()} + {#if formattedLabel} + <div class={cn("font-medium", labelClassName)}> + {#if typeof formattedLabel === "function"} + {@render formattedLabel()} + {:else} + {formattedLabel} + {/if} + </div> + {/if} +{/snippet} + +<TooltipPrimitive.Root variant="none"> + <div + bind:this={ref} + class={cn( + "grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", + className + )} + {...restProps} + > + {#if !nestLabel} + {@render TooltipLabel()} + {/if} + <div class="grid gap-1.5"> + {#each visibleSeries as item, i (item.key + i)} + {@const key = `${nameKey || item.key || item.label || "value"}`} + {@const itemConfig = getPayloadConfigFromPayload( + chart.config, + item, + key, + chartCtx.tooltip.data + )} + {@const indicatorColor = color || item.config?.color || item.color} + <div + class={cn( + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center" + )} + > + {#if formatter && item.value !== undefined && item.label} + {@render formatter({ + value: item.value, + name: item.label, + item, + index: i, + payload: visibleSeries, + })} + {:else} + {#if itemConfig?.icon} + <itemConfig.icon /> + {:else if !hideIndicator} + <div + style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};" + class={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", { + "h-2.5 w-2.5": indicator === "dot", + "h-full w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + })} + ></div> + {/if} + <div + class={cn( + "flex flex-1 shrink-0 justify-between leading-none", + nestLabel ? "items-end" : "items-center" + )} + > + <div class="grid gap-1.5"> + {#if nestLabel} + {@render TooltipLabel()} + {/if} + <span class="text-muted-foreground"> + {itemConfig?.label || item.label} + </span> + </div> + {#if item.value !== undefined} + <span class="font-mono font-medium text-foreground tabular-nums"> + {item.value.toLocaleString()} + </span> + {/if} + </div> + {/if} + </div> + {/each} + </div> + </div> +</TooltipPrimitive.Root> |
