diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:30 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:30 +0800 |
| commit | 06a7b1668b44f111eca5d08bc8c2c9d41d8e617e (patch) | |
| tree | 0c7220a22b866d3e298f756b42f565f4f674152c /frontend/src/lib/components/ui/chart | |
| parent | 95215e84e045602db2370586f6089a7f9f91b33b (diff) | |
| download | strategy-lab-06a7b1668b44f111eca5d08bc8c2c9d41d8e617e.tar.gz | |
chore(frontend): shadcn-svelte / Tailwind / layerchart 工具链初始化
[变更性质]
本轮为维护性依赖与配置初始化,不改变业务页面(页面迁移见后续提交)。
[维护内容]
按 Leader 返工指令,前端 UI 组件库全量迁移 shadcn-svelte,图表渲染
从 echarts 换为 layerchart,需要一次性引入配套依赖与构建配置。
[实现方案]
package.json/pnpm-lock.yaml:tailwindcss@4、@tailwindcss/vite、
tw-animate-css、shadcn-svelte、bits-ui、tailwind-variants、clsx、
tailwind-merge、@lucide/svelte、layerchart、@fontsource-variable/inter
与 @types/node;echarts 保留至页面迁移完成后移除。pnpm-workspace.yaml
声明 allowBuilds(esbuild、@tailwindcss/oxide)。components.json 采用
mira preset,别名 $lib。tsconfig 新增 $lib paths;vite.config.ts 加入
tailwindcss 插件、$lib alias(vitest 需要)与 test 环境配置。
src/lib/components/ 为 shadcn 生成的 UI 组件源码(button/input/label/
card/badge/alert/select/table/tabs/separator/chart),src/lib/utils.ts
为 cn 工具。app.css 引入 tailwind 与 shadcn CSS 变量并调整为 teal
主题(primary #0f766e、radius 0.375rem、双主题 chart-1..5),保留
legacy 选择器(banner/beat/table.data/metric 等),mobileCss.test.ts
依赖其语义断言。
[影响范围]
构建链与主题变量;后续页面提交逐步消费这些组件,npm run check/test/
build 已全绿验证。
Diffstat (limited to 'frontend/src/lib/components/ui/chart')
5 files changed, 371 insertions, 0 deletions
diff --git a/frontend/src/lib/components/ui/chart/chart-container.svelte b/frontend/src/lib/components/ui/chart/chart-container.svelte new file mode 100644 index 0000000..3687ea0 --- /dev/null +++ b/frontend/src/lib/components/ui/chart/chart-container.svelte @@ -0,0 +1,80 @@ +<script lang="ts"> + import { cn, type WithElementRef } from "$lib/utils.js"; + import ChartStyle from "./chart-style.svelte"; + import { setChartContext, type ChartConfig } from "./chart-utils.js"; + import type { HTMLAttributes } from "svelte/elements"; + + const uid = $props.id(); + + let { + ref = $bindable(null), + id = uid, + class: className, + children, + config, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> & { + config: ChartConfig; + } = $props(); + + const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`); + + setChartContext({ + get config() { + return config; + }, + }); +</script> + +<div + bind:this={ref} + data-chart={chartId} + data-slot="chart" + class={cn( + "flex aspect-video justify-center overflow-visible text-xs", + // Overrides + // + // Stroke around dots/marks when hovering + "[&_.lc-highlight-point]:stroke-transparent", + // override the default stroke color of lines + "[&_.lc-line]:stroke-border/50", + + // by default, layerchart shows a line intersecting the point when hovering, this hides that + "[&_.lc-highlight-line]:stroke-0", + + // by default, when you hover a point on a stacked series chart, it will drop the opacity + // of the other series, this overrides that + "[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text]:text-xs [&_.lc-text-svg]:overflow-visible", + + // We don't want the little tick lines between the axis labels and the chart, so we remove + // the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every + // chart. + "[&_.lc-axis-tick]:stroke-0", + + // We don't want to display the rule on the x/y axis, as there is already going to be + // a grid line there and rule ends up overlapping the marks because it is rendered after + // the marks + "[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0", + "[&_.lc-grid-x-radial-circle]:stroke-border [&_.lc-grid-x-radial-line]:stroke-border", + "[&_.lc-grid-y-radial-circle]:stroke-border [&_.lc-grid-y-radial-line]:stroke-border", + + // Legend adjustments + "[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5", + "[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4", + "[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]", + + // Labels + "[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent", + + // Tick labels on th x/y axes + "[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal", + "[&_.lc-tooltip-rects-g]:fill-transparent", + "[&_.lc-layout-svg-g]:fill-transparent", + "[&_.lc-root-container]:w-full", + className + )} + {...restProps} +> + <ChartStyle id={chartId} {config} /> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/chart/chart-style.svelte b/frontend/src/lib/components/ui/chart/chart-style.svelte new file mode 100644 index 0000000..74c2b8a --- /dev/null +++ b/frontend/src/lib/components/ui/chart/chart-style.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { THEMES, type ChartConfig } from "./chart-utils.js"; + + let { id, config }: { id: string; config: ChartConfig } = $props(); + + const colorConfig = $derived( + config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null + ); + + const themeContents = $derived.by(() => { + if (!colorConfig || !colorConfig.length) return; + + const themeContents = []; + for (const [_theme, prefix] of Object.entries(THEMES)) { + let content = `${prefix} [data-chart=${id}] {\n`; + const color = colorConfig.map(([key, itemConfig]) => { + const theme = _theme as keyof typeof itemConfig.theme; + const color = itemConfig.theme?.[theme] || itemConfig.color; + return color ? `\t--color-${key}: ${color};` : null; + }); + + content += color.join("\n") +"\n}"; + + themeContents.push(content); + } + + return themeContents.join("\n"); + }); +</script> + +{#if themeContents} + {#key id} + <svelte:element this={"style"}> + {themeContents} + </svelte:element> + {/key} +{/if} 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> diff --git a/frontend/src/lib/components/ui/chart/chart-utils.ts b/frontend/src/lib/components/ui/chart/chart-utils.ts new file mode 100644 index 0000000..6e1f000 --- /dev/null +++ b/frontend/src/lib/components/ui/chart/chart-utils.ts @@ -0,0 +1,68 @@ +import { getContext, setContext, type Component, type Snippet } from "svelte"; +import type { Tooltip } from "layerchart"; + +export const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: string; + icon?: Component; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<keyof typeof THEMES, string> } + ); +}; + +export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never; + +export type TooltipPayload = Tooltip.TooltipSeries; + +// Helper to extract item config from a payload. +export function getPayloadConfigFromPayload( + config: ChartConfig, + payload: TooltipPayload, + key: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: Record<string, any> | null +) { + if (typeof payload !== "object" || payload === null) return undefined; + + const payloadConfig = + "config" in payload && typeof payload.config === "object" && payload.config !== null + ? payload.config + : undefined; + + let configLabelKey: string = key; + + if (payload.key === key) { + configLabelKey = payload.key; + } else if (payload.label === key) { + configLabelKey = payload.label; + } else if (key in payload && typeof payload[key as keyof typeof payload] === "string") { + configLabelKey = payload[key as keyof typeof payload] as string; + } else if ( + payloadConfig !== undefined && + key in payloadConfig && + typeof payloadConfig[key as keyof typeof payloadConfig] === "string" + ) { + configLabelKey = payloadConfig[key as keyof typeof payloadConfig] as string; + } else if (data != null && key in data && typeof data[key] === "string") { + configLabelKey = data[key] as string; + } + + return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]; +} + +type ChartContextValue = { + config: ChartConfig; +}; + +const chartContextKey = Symbol("chart-context"); + +export function setChartContext(value: ChartContextValue) { + return setContext(chartContextKey, value); +} + +export function useChart() { + return getContext<ChartContextValue>(chartContextKey); +} diff --git a/frontend/src/lib/components/ui/chart/index.ts b/frontend/src/lib/components/ui/chart/index.ts new file mode 100644 index 0000000..f22375e --- /dev/null +++ b/frontend/src/lib/components/ui/chart/index.ts @@ -0,0 +1,6 @@ +import ChartContainer from "./chart-container.svelte"; +import ChartTooltip from "./chart-tooltip.svelte"; + +export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js"; + +export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip }; |
