diff --git a/README.md b/README.md index f4ae949b0..7fdbafa8f 100644 --- a/README.md +++ b/README.md @@ -444,19 +444,19 @@ Method: fresh `npm pack --ignore-scripts` tarball → temporary consumer → min | Public named import | Runtime | gzip cold-consumer bundle | |---|---:|---:| | `import { LineChart } from "semiotic"` | browser | **153.1 KiB** | -| `import { LineChart } from "semiotic/xy"` | browser | **153.3 KiB** | +| `import { LineChart } from "semiotic/xy"` | browser | **153.2 KiB** | | `import { BarChart } from "semiotic/ordinal"` | browser | **123.5 KiB** | -| `import { SankeyDiagram } from "semiotic/network"` | browser | **128.1 KiB** | -| `import { RealtimeLineChart } from "semiotic/realtime"` | browser | **122.8 KiB** | -| `import { RingBuffer } from "semiotic/realtime/core"` | browser | **170.6 KiB** | +| `import { SankeyDiagram } from "semiotic/network"` | browser | **127.9 KiB** | +| `import { RealtimeLineChart } from "semiotic/realtime"` | browser | **122.9 KiB** | +| `import { RingBuffer } from "semiotic/realtime/core"` | browser | **170.3 KiB** | | `import { useStreamStatus } from "semiotic/realtime/react"` | browser | **0.6 KiB** | | `import { GaltonBoardChart } from "semiotic/physics"` | browser | **134.2 KiB** | | `import { MATTER_PHYSICS_CAPABILITIES } from "semiotic/physics/matter"` | browser | **0.2 KiB** | | `import { RAPIER_PHYSICS_CAPABILITIES } from "semiotic/physics/rapier"` | browser | **0.2 KiB** | -| `import { renderChart } from "semiotic/server"` | node | **269.8 KiB** | +| `import { renderChart } from "semiotic/server"` | node | **269.5 KiB** | | `import { generateFrameSVGs } from "semiotic/server/edge"` | node | **164.5 KiB** | -| `import { renderToImage } from "semiotic/server/node"` | node | **270.2 KiB** | -| `import { suggestCharts } from "semiotic/ai"` | browser | **193.1 KiB** | +| `import { renderToImage } from "semiotic/server/node"` | node | **270.0 KiB** | +| `import { suggestCharts } from "semiotic/ai"` | browser | **192.8 KiB** | | `import { suggestCharts } from "semiotic/ai/core"` | browser | **36.3 KiB** | | `import { bin } from "semiotic/data"` | browser | **0.4 KiB** | | `import { ChoroplethMap } from "semiotic/geo"` | browser | **108.5 KiB** | @@ -464,8 +464,8 @@ Method: fresh `npm pack --ignore-scripts` tarball → temporary consumer → min | `import { resolveThemePreset } from "semiotic/themes"` | browser | **3.4 KiB** | | `import { resolveThemePreset } from "semiotic/themes/core"` | browser | **3.4 KiB** | | `import { ThemeProvider } from "semiotic/themes/react"` | browser | **4.4 KiB** | -| `import { validateProps } from "semiotic/utils"` | browser | **8.2 KiB** | -| `import { smartTickFormat } from "semiotic/utils/core"` | browser | **0.7 KiB** | +| `import { validateProps } from "semiotic/utils"` | browser | **8.4 KiB** | +| `import { smartTickFormat } from "semiotic/utils/core"` | browser | **0.9 KiB** | | `import { useReducedMotion } from "semiotic/utils/react"` | browser | **0.3 KiB** | | `import { waffleLayout } from "semiotic/recipes"` | browser | **2.5 KiB** | | `import { waffleLayout } from "semiotic/recipes/core"` | browser | **2.5 KiB** | diff --git a/benchmarks/setup/cold-consumer-imports.json b/benchmarks/setup/cold-consumer-imports.json index ce2827400..6cf289493 100644 --- a/benchmarks/setup/cold-consumer-imports.json +++ b/benchmarks/setup/cold-consumer-imports.json @@ -44,54 +44,54 @@ "importPath": "semiotic", "symbol": "LineChart", "platform": "browser", - "rawBytes": 489762, - "gzipBytes": 156803, - "packedPackageInputFiles": 50 + "rawBytes": 488979, + "gzipBytes": 156729, + "packedPackageInputFiles": 51 }, { "exportKey": "./xy", "importPath": "semiotic/xy", "symbol": "LineChart", "platform": "browser", - "rawBytes": 489715, - "gzipBytes": 156957, - "packedPackageInputFiles": 30 + "rawBytes": 488932, + "gzipBytes": 156892, + "packedPackageInputFiles": 31 }, { "exportKey": "./ordinal", "importPath": "semiotic/ordinal", "symbol": "BarChart", "platform": "browser", - "rawBytes": 410892, - "gzipBytes": 126437, - "packedPackageInputFiles": 23 + "rawBytes": 410679, + "gzipBytes": 126495, + "packedPackageInputFiles": 24 }, { "exportKey": "./network", "importPath": "semiotic/network", "symbol": "SankeyDiagram", "platform": "browser", - "rawBytes": 403912, - "gzipBytes": 131126, - "packedPackageInputFiles": 23 + "rawBytes": 402840, + "gzipBytes": 130982, + "packedPackageInputFiles": 25 }, { "exportKey": "./realtime", "importPath": "semiotic/realtime", "symbol": "RealtimeLineChart", "platform": "browser", - "rawBytes": 389358, - "gzipBytes": 125774, - "packedPackageInputFiles": 26 + "rawBytes": 388894, + "gzipBytes": 125859, + "packedPackageInputFiles": 27 }, { "exportKey": "./realtime/core", "importPath": "semiotic/realtime/core", "symbol": "RingBuffer", "platform": "browser", - "rawBytes": 550535, - "gzipBytes": 174662, - "packedPackageInputFiles": 13 + "rawBytes": 548218, + "gzipBytes": 174337, + "packedPackageInputFiles": 14 }, { "exportKey": "./realtime/react", @@ -107,8 +107,8 @@ "importPath": "semiotic/physics", "symbol": "GaltonBoardChart", "platform": "browser", - "rawBytes": 413635, - "gzipBytes": 137471, + "rawBytes": 413241, + "gzipBytes": 137399, "packedPackageInputFiles": 16 }, { @@ -134,8 +134,8 @@ "importPath": "semiotic/server", "symbol": "renderChart", "platform": "node", - "rawBytes": 873026, - "gzipBytes": 276250, + "rawBytes": 871485, + "gzipBytes": 276009, "packedPackageInputFiles": 4 }, { @@ -143,17 +143,17 @@ "importPath": "semiotic/server/edge", "symbol": "generateFrameSVGs", "platform": "node", - "rawBytes": 525083, - "gzipBytes": 168398, - "packedPackageInputFiles": 15 + "rawBytes": 524436, + "gzipBytes": 168402, + "packedPackageInputFiles": 16 }, { "exportKey": "./server/node", "importPath": "semiotic/server/node", "symbol": "renderToImage", "platform": "node", - "rawBytes": 873989, - "gzipBytes": 276720, + "rawBytes": 872448, + "gzipBytes": 276450, "packedPackageInputFiles": 4 }, { @@ -161,17 +161,17 @@ "importPath": "semiotic/ai", "symbol": "suggestCharts", "platform": "browser", - "rawBytes": 711112, - "gzipBytes": 197701, - "packedPackageInputFiles": 50 + "rawBytes": 710345, + "gzipBytes": 197430, + "packedPackageInputFiles": 51 }, { "exportKey": "./ai/core", "importPath": "semiotic/ai/core", "symbol": "suggestCharts", "platform": "browser", - "rawBytes": 209274, - "gzipBytes": 37135, + "rawBytes": 209421, + "gzipBytes": 37184, "packedPackageInputFiles": 2 }, { @@ -188,8 +188,8 @@ "importPath": "semiotic/geo", "symbol": "ChoroplethMap", "platform": "browser", - "rawBytes": 329494, - "gzipBytes": 111116, + "rawBytes": 329587, + "gzipBytes": 111144, "packedPackageInputFiles": 19 }, { @@ -206,8 +206,8 @@ "importPath": "semiotic/themes", "symbol": "resolveThemePreset", "platform": "browser", - "rawBytes": 12436, - "gzipBytes": 3491, + "rawBytes": 12437, + "gzipBytes": 3488, "packedPackageInputFiles": 3 }, { @@ -215,8 +215,8 @@ "importPath": "semiotic/themes/core", "symbol": "resolveThemePreset", "platform": "browser", - "rawBytes": 12436, - "gzipBytes": 3491, + "rawBytes": 12437, + "gzipBytes": 3487, "packedPackageInputFiles": 2 }, { @@ -224,8 +224,8 @@ "importPath": "semiotic/themes/react", "symbol": "ThemeProvider", "platform": "browser", - "rawBytes": 16151, - "gzipBytes": 4483, + "rawBytes": 16152, + "gzipBytes": 4482, "packedPackageInputFiles": 3 }, { @@ -233,18 +233,18 @@ "importPath": "semiotic/utils", "symbol": "validateProps", "platform": "browser", - "rawBytes": 111495, - "gzipBytes": 8417, - "packedPackageInputFiles": 14 + "rawBytes": 112008, + "gzipBytes": 8602, + "packedPackageInputFiles": 15 }, { "exportKey": "./utils/core", "importPath": "semiotic/utils/core", "symbol": "smartTickFormat", "platform": "browser", - "rawBytes": 1561, - "gzipBytes": 676, - "packedPackageInputFiles": 13 + "rawBytes": 2074, + "gzipBytes": 872, + "packedPackageInputFiles": 14 }, { "exportKey": "./utils/react", @@ -262,7 +262,7 @@ "platform": "browser", "rawBytes": 5802, "gzipBytes": 2602, - "packedPackageInputFiles": 9 + "packedPackageInputFiles": 10 }, { "exportKey": "./recipes/core", @@ -271,7 +271,7 @@ "platform": "browser", "rawBytes": 5710, "gzipBytes": 2584, - "packedPackageInputFiles": 9 + "packedPackageInputFiles": 10 }, { "exportKey": "./recipes/react", diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-chromium-darwin.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-chromium-darwin.png index dcf4ac246..7fddb1932 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-chromium-darwin.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-chromium-darwin.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-chromium-linux.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-chromium-linux.png index 04605660d..7bf48b115 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-chromium-linux.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-chromium-linux.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-firefox-darwin.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-firefox-darwin.png index b591d64ef..f44d2a60e 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-firefox-darwin.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-firefox-darwin.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-firefox-linux.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-firefox-linux.png index 6b8bf8869..64749d87c 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-firefox-linux.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-firefox-linux.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-chromium-darwin.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-chromium-darwin.png index 1b66a914d..b7929c992 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-chromium-darwin.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-chromium-darwin.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-chromium-linux.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-chromium-linux.png index 1c8a74f80..976fca988 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-chromium-linux.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-chromium-linux.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-firefox-darwin.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-firefox-darwin.png index 01fdf07be..2b56fc745 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-firefox-darwin.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-firefox-darwin.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-firefox-linux.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-firefox-linux.png index 7a16c6df3..ec965a0d4 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-firefox-linux.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-firefox-linux.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-webkit-darwin.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-webkit-darwin.png index 561f04820..63f062cc9 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-webkit-darwin.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-webkit-darwin.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-webkit-linux.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-webkit-linux.png index 2f26d9f16..4f61802cd 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-webkit-linux.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-ribbon-webkit-linux.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-webkit-darwin.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-webkit-darwin.png index 3700d9aa1..f9a09619a 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-webkit-darwin.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-webkit-darwin.png differ diff --git a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-webkit-linux.png b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-webkit-linux.png index 19078ea91..af712e4b8 100644 Binary files a/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-webkit-linux.png and b/integration-tests/ssr-parity.spec.ts-snapshots/ssr-csr-bump-webkit-linux.png differ diff --git a/scripts/check-entry-graph-size.mjs b/scripts/check-entry-graph-size.mjs index 72a603908..5ddd3298e 100644 --- a/scripts/check-entry-graph-size.mjs +++ b/scripts/check-entry-graph-size.mjs @@ -35,7 +35,14 @@ const ENTRY_GRAPHS = [ // formerly React-only value card render-evidence-capable through // semiotic/server. This is a measured sub-KiB graph increase, not a new // shared runtime; keep the one-KiB headroom narrow. - { entry: "semiotic.module.min.js", label: "semiotic", limitKb: 376 }, + // Bumped 376→377: SVG axis/legend/title text now carries a plain + // `font-size` presentation-attribute fallback alongside the existing + // CSS-var style, so consumers with no CSS engine over the SVG (a + // style-stripping sanitizer, the Figma plugin's importer, static + // rasterizers) still get a sane size instead of silently inheriting the + // host document's. Sub-KiB text-attribute growth across the shared SVG + // overlay chunk, not a new dependency. + { entry: "semiotic.module.min.js", label: "semiotic", limitKb: 377 }, { entry: "xy.module.min.js", label: "xy", limitKb: 150 }, { entry: "ordinal.module.min.js", label: "ordinal", limitKb: 130 }, { entry: "network.module.min.js", label: "network", limitKb: 140 }, @@ -54,10 +61,10 @@ const ENTRY_GRAPHS = [ // Bumped 475→480 (3.8.6): the current shared capability/accessibility graph // measures 477.3 KB gzip after the portable-policy and audit work; this keeps // a narrow 2.7 KB headroom without changing the canonical AI catalog. - { entry: "semiotic-ai.module.min.js", label: "ai", limitKb: 480 }, + { entry: "semiotic-ai.module.min.js", label: "ai", limitKb: 481 }, { entry: "semiotic-recipes.module.min.js", label: "recipes", limitKb: 100 }, { entry: "semiotic-utils.module.min.js", label: "utils", limitKb: 110 }, - { entry: "semiotic-value.module.min.js", label: "value", limitKb: 25 }, + { entry: "semiotic-value.module.min.js", label: "value", limitKb: 25 } ] function collectImports(filePath, seen = new Set()) { @@ -128,15 +135,17 @@ for (const { entry, label, limitKb } of ENTRY_GRAPHS) { totalGzip, totalRaw, limit, - ok, + ok }) } -console.log("Chunk-aware entry graph sizes (entry + static ESM imports, gzip):\n") +console.log( + "Chunk-aware entry graph sizes (entry + static ESM imports, gzip):\n" +) for (const r of rows) { const mark = r.ok ? "✓" : "✗" console.log( - ` ${mark} ${r.label.padEnd(10)} ${formatKb(r.totalGzip).padStart(10)} / ${formatKb(r.limit).padStart(10)} (${r.files} files, raw ${formatKb(r.totalRaw)})`, + ` ${mark} ${r.label.padEnd(10)} ${formatKb(r.totalGzip).padStart(10)} / ${formatKb(r.limit).padStart(10)} (${r.files} files, raw ${formatKb(r.totalRaw)})` ) } @@ -146,7 +155,7 @@ if (failed) { console.error( "\n✗ One or more entry graphs exceed their chunk-aware gzip budget.\n" + " Facades alone are ~2 KB; budgets measure the reachable shared-chunk graph.\n" + - " Raise limits only with a PR note, or split the heavy shared chunk.", + " Raise limits only with a PR note, or split the heavy shared chunk." ) process.exit(1) } diff --git a/src/components/Legend.tsx b/src/components/Legend.tsx index dc53fbfbe..d28f8f042 100644 --- a/src/components/Legend.tsx +++ b/src/components/Legend.tsx @@ -151,7 +151,7 @@ const renderLegendGroupVertical = ( />} {renderedType} {isIsolated && } - + {item.label} @@ -279,7 +279,7 @@ const renderLegendGroupHorizontal = ( />} {renderedType} {isIsolated && } - + {item.label} @@ -346,6 +346,7 @@ const renderVerticalGroup = ({ key={`legend-text-${i}`} y={layout.labelY} className="legend-group-label" + fontSize={12} style={{ fontSize: "var(--semiotic-legend-font-size, 12px)" }} fill="var(--semiotic-text, #333)" > @@ -433,6 +434,7 @@ const renderHorizontalGroup = ({ transform={`translate(${offset},${verticalOffset}) rotate(90)`} textAnchor="start" className="legend-group-label" + fontSize={12} style={{ fontSize: "var(--semiotic-legend-font-size, 12px)" }} fill="var(--semiotic-text, #333)" > @@ -630,6 +632,7 @@ export default function Legend(props: LegendProps) { y={16} x={width / 2} textAnchor="middle" + fontSize={12} style={{ fontSize: "var(--semiotic-legend-font-size, 12px)" }} fill="var(--semiotic-text, #333)" > diff --git a/src/components/charts/ordinal/GaugeChart.tsx b/src/components/charts/ordinal/GaugeChart.tsx index cf9547ff6..b04b412ed 100644 --- a/src/components/charts/ordinal/GaugeChart.tsx +++ b/src/components/charts/ordinal/GaugeChart.tsx @@ -393,6 +393,7 @@ export const GaugeChart = forwardRef(function GaugeChart(props: GaugeChartProps, x={lx} y={ly} textAnchor={textAnchor} dominantBaseline={dominantBaseline} fill="var(--semiotic-text-secondary, #666)" + fontSize={10} style={{ userSelect: "none", fontSize: "var(--semiotic-gauge-label-font-size, 10px)", diff --git a/src/components/charts/realtime/RealtimeHistogram.tsx b/src/components/charts/realtime/RealtimeHistogram.tsx index bf9c2a76a..c85bd5e39 100644 --- a/src/components/charts/realtime/RealtimeHistogram.tsx +++ b/src/components/charts/realtime/RealtimeHistogram.tsx @@ -326,7 +326,6 @@ export const RealtimeHistogram = forwardRef( // LinkedBrush integration via selection store const brushConfig = normalizeLinkedBrush(linkedBrush) const timeField = typeof timeAccessor === "string" ? timeAccessor : "time" - const _valueField = typeof valueAccessor === "string" ? valueAccessor : "value" const brushHook = useBrushSelection({ name: brushConfig?.name || "__unused_hist_brush__", diff --git a/src/components/charts/shared/annotationRules.tsx b/src/components/charts/shared/annotationRules.tsx index 2c8787655..748a3b9a1 100644 --- a/src/components/charts/shared/annotationRules.tsx +++ b/src/components/charts/shared/annotationRules.tsx @@ -4,8 +4,15 @@ import { packEnclose } from "d3-hierarchy" import { area as d3Area, curveLinear, curveMonotoneX, curveMonotoneY, curveStep, curveStepAfter, curveStepBefore, curveBasis, curveCardinal, curveCatmullRom } from "d3-shape" import type { CurveFactory } from "d3-shape" import type { AnnotationContext } from "../../realtime/types" +import type { CurveType } from "../../stream/types" import { loess } from "./loess" -import { linearRegression, polynomialRegression } from "./leastSquaresRegression" +import { + linearRegression, + polynomialRegression, + fitLinearForForecast, + forecastIntervalStats, + confidenceZScore, +} from "./leastSquaresRegression" import { resolveX, resolveY, resolveAnchoredPosition, isInBounds } from "./annotationResolvers" import type { Datum } from "./datumTypes" import { applyAnnotationEmphasis, type AnnotationRenderPair } from "./annotationHierarchy" @@ -17,7 +24,10 @@ import { resolveSvgFill } from "./hatchFill" export { applyAnnotationEmphasis, type AnnotationRenderPair } from "./annotationHierarchy" -const CURVE_FACTORIES: Record = { +// `"natural"` is a valid CurveType but unsupported here — Partial> +// keeps that an intentional omission rather than a required-but-missing key, +// while still catching a typo'd key against the real CurveType union. +const CURVE_FACTORIES: Partial> = { linear: curveLinear, monotoneX: curveMonotoneX, monotoneY: curveMonotoneY, @@ -656,7 +666,7 @@ export function createDefaultAnnotationRules( if (bounded.length < 2) return null // Build envelope area using d3-shape area generator with curve interpolation - const curveFn = CURVE_FACTORIES[context.curve || "linear"] || curveLinear + const curveFn = CURVE_FACTORIES[(context.curve || "linear") as CurveType] || curveLinear const envelopeArea = d3Area() .x((d) => scaleX(d[xAcc])) .y0((d) => scaleY(d[lowerAcc])) @@ -693,7 +703,6 @@ export function createDefaultAnnotationRules( const data = context.data || [] if (data.length < 2) return null const yAcc = context.yAccessor || "y" - const _xAcc = context.xAccessor || "x" const scaleX = context.scales?.x ?? context.scales?.time const scaleY = context.scales?.y ?? context.scales?.value if (!scaleX || !scaleY) return null @@ -799,36 +808,15 @@ export function createDefaultAnnotationRules( 0 ) } else { - // Linear regression (inline — no dependency needed) - const n = points.length - let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0 - for (const [x, y] of points) { - sumX += x; sumY += y; sumXX += x * x; sumXY += x * y - } - const det = n * sumXX - sumX * sumX - if (Math.abs(det) < 1e-12) return null - const slope = (n * sumXY - sumX * sumY) / det - const intercept = (sumY - slope * sumX) / n - predict = (x: number) => intercept + slope * x + const fit = fitLinearForForecast(points) + if (!fit) return null + predict = fit } - // Residual standard error const n = points.length - const residuals = points.map(([x, y]) => y - predict(x)) - const sse = residuals.reduce((s, r) => s + r * r, 0) - const se = Math.sqrt(sse / Math.max(n - 2, 1)) - - // Mean of x and sum of squared deviations - const meanX = points.reduce((s, p) => s + p[0], 0) / n - const ssX = points.reduce((s, p) => s + (p[0] - meanX) ** 2, 0) - - // Confidence z-score + const { se, meanX, ssX } = forecastIntervalStats(points, predict) const confidence = ann.confidence ?? 0.95 - // Approximate z for common confidence levels - const z = confidence >= 0.99 ? 2.576 - : confidence >= 0.95 ? 1.96 - : confidence >= 0.9 ? 1.645 - : 1.0 + const z = confidenceZScore(confidence) // Generate forecast x-values const steps = ann.steps ?? 5 diff --git a/src/components/charts/shared/auditMobileVisualization.ts b/src/components/charts/shared/auditMobileVisualization.ts index 9519a7940..95bbc0d25 100644 --- a/src/components/charts/shared/auditMobileVisualization.ts +++ b/src/components/charts/shared/auditMobileVisualization.ts @@ -1,5 +1,6 @@ import type { Datum } from "./datumTypes" import { resolveResponsiveRules } from "./responsiveRules" +import { XY_WITH_AXES_CHARTS, ORDINAL_CHARTS, POINT_TARGET_RADIUS_PROP } from "./chartFamilySets" /** * auditMobileVisualization - static mobile-readiness triage for Semiotic chart @@ -102,46 +103,17 @@ export interface MobileVisualizationContract { const REFERENCE = "Mobile visualization audit: informed by MobileVisFixer, mobile exploratory interaction research, small-multiple mobile studies, responsive visualization grammars, constraint-based breakpoints, and WCAG 2.2 input modalities." +// XY_WITH_AXES_CHARTS plus the two realtime charts that share its axis-driven +// interaction model (mobile audit treats them the same as their static kin). const XY_CHARTS = new Set([ - "LineChart", - "AreaChart", - "DifferenceChart", - "StackedAreaChart", - "Scatterplot", - "ConnectedScatterplot", - "BubbleChart", - "QuadrantChart", - "MultiAxisLineChart", - "CandlestickChart", - "Heatmap", - "MinimapChart", + ...XY_WITH_AXES_CHARTS, "RealtimeLineChart", "RealtimeHeatmap", ]) -const ORDINAL_CHARTS = new Set([ - "BarChart", - "StackedBarChart", - "GroupedBarChart", - "SwarmPlot", - "BoxPlot", - "Histogram", - "ViolinPlot", - "DotPlot", - "RidgelinePlot", - "FunnelChart", - "LikertChart", - "SwimlaneChart", -]) - -const POINT_RADIUS_PROPS: Record = { - Scatterplot: ["pointRadius", "hoverRadius"], - BubbleChart: ["pointRadius", "hoverRadius"], - ConnectedScatterplot: ["pointRadius", "hoverRadius"], - QuadrantChart: ["pointRadius", "hoverRadius"], - SwarmPlot: ["pointRadius", "hoverRadius"], - DotPlot: ["dotRadius", "hoverRadius"], -} +const POINT_RADIUS_PROPS: Record = Object.fromEntries( + Object.entries(POINT_TARGET_RADIUS_PROP).map(([component, prop]) => [component, [prop, "hoverRadius"]]) +) function isObject(value: unknown): value is Datum { return !!value && typeof value === "object" && !Array.isArray(value) diff --git a/src/components/charts/shared/chartFamilySets.ts b/src/components/charts/shared/chartFamilySets.ts index 61fbf9262..6131cde2d 100644 --- a/src/components/charts/shared/chartFamilySets.ts +++ b/src/components/charts/shared/chartFamilySets.ts @@ -73,6 +73,21 @@ export const XY_WITH_AXES_CHARTS = new Set([ "MinimapChart" ]) +export const ORDINAL_CHARTS = new Set([ + "BarChart", + "StackedBarChart", + "GroupedBarChart", + "SwarmPlot", + "BoxPlot", + "Histogram", + "ViolinPlot", + "DotPlot", + "RidgelinePlot", + "FunnelChart", + "LikertChart", + "SwimlaneChart" +]) + // Charts whose default presentation animates continuously / loops. export const PHYSICS_MOTION_CHARTS = new Set([ "StreamPhysicsFrame", diff --git a/src/components/charts/shared/diagnoseConfig.ts b/src/components/charts/shared/diagnoseConfig.ts index 68b74a548..7d38a21b2 100644 --- a/src/components/charts/shared/diagnoseConfig.ts +++ b/src/components/charts/shared/diagnoseConfig.ts @@ -39,8 +39,14 @@ export type { Diagnosis, DiagnosisResult } from "./diagnoseTypes" // Named color schemes that resolve to a palette (colorUtils.COLOR_SCHEMES). // A string `colorScheme` outside this set silently falls back to the default -// palette, so a typo produces wrong colors with no error. Kept in sync with -// COLOR_SCHEMES by a drift test in diagnoseConfig.test.ts. +// palette, so a typo produces wrong colors with no error. Kept as a literal +// list rather than `new Set(Object.keys(COLOR_SCHEMES))` — importing +// colorUtils.ts here to read just the key names still pulls its full +// category10/tableau10/set3/sequential-interpolator data (and d3-scale) into +// every diagnoseConfig consumer, including the size-budgeted semiotic/ai/core +// bundle, at ~1.5% raw / ~4.6% gzip cost for that one bundle — measured via +// npm run check:cold-consumer. Kept in sync with COLOR_SCHEMES by a drift +// test in diagnoseConfig.test.ts. const KNOWN_COLOR_SCHEMES = new Set([ "category10", "tableau10", "set3", "blues", "reds", "greens", "oranges", "purples", "greys", diff --git a/src/components/charts/shared/hooks.ts b/src/components/charts/shared/hooks.ts index a6698a482..73eb6a246 100644 --- a/src/components/charts/shared/hooks.ts +++ b/src/components/charts/shared/hooks.ts @@ -563,6 +563,7 @@ export function getCrosshairProps( export { useChartLegendAndMargin, useLegendInteraction, + distinctCategories, } from "./useChartLegend" export type { LegendPosition, diff --git a/src/components/charts/shared/leastSquaresRegression.ts b/src/components/charts/shared/leastSquaresRegression.ts index 2944252d2..c326d6337 100644 --- a/src/components/charts/shared/leastSquaresRegression.ts +++ b/src/components/charts/shared/leastSquaresRegression.ts @@ -139,3 +139,62 @@ export function polynomialRegression( equation: [...coefficients].reverse() } } + +/** + * Full-precision (unrounded) linear fit for forecast prediction-interval + * math — unlike {@link linearRegression}, which rounds gradient/intercept to + * two decimals for display. That rounding is fine for a trend-line + * annotation but would leak into the residual/standard-error/interval math + * a forecast band computes from `predict`. Returns null when x has zero + * variance (singular normal equations) rather than falling back to a + * zero-gradient line, so callers can skip rendering instead of drawing a + * misleading flat forecast. Shared by the `forecast` annotation rule + * (`annotationRules.tsx`) and the LineChart forecast-segment overlay + * (`statisticalOverlays.ts`). + */ +export function fitLinearForForecast( + points: ReadonlyArray +): ((x: number) => number) | null { + const n = points.length + let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0 + for (const [x, y] of points) { + sumX += x; sumY += y; sumXX += x * x; sumXY += x * y + } + const det = n * sumXX - sumX * sumX + if (Math.abs(det) < 1e-12) return null + const slope = (n * sumXY - sumX * sumY) / det + const intercept = (sumY - slope * sumX) / n + return (x: number) => intercept + slope * x +} + +/** + * Residual standard error plus the mean/sum-of-squared-deviations of x that + * a forecast prediction-interval formula needs, given training points and an + * already-fitted `predict` (linear or polynomial — this part doesn't care + * which). Shared by the same two forecast call sites as + * {@link fitLinearForForecast}. + */ +export function forecastIntervalStats( + points: ReadonlyArray, + predict: (x: number) => number +): { se: number; meanX: number; ssX: number } { + const n = points.length + const residuals = points.map(([x, y]) => y - predict(x)) + const sse = residuals.reduce((s, r) => s + r * r, 0) + const se = Math.sqrt(sse / Math.max(n - 2, 1)) + const meanX = points.reduce((s, p) => s + p[0], 0) / n + const ssX = points.reduce((s, p) => s + (p[0] - meanX) ** 2, 0) + return { se, meanX, ssX } +} + +/** + * Approximate z-score for the common one/two-sided confidence levels a + * forecast/envelope prediction interval rounds to. Shared by the same two + * forecast call sites as {@link fitLinearForForecast}. + */ +export function confidenceZScore(confidence: number): number { + return confidence >= 0.99 ? 2.576 + : confidence >= 0.95 ? 1.96 + : confidence >= 0.9 ? 1.645 + : 1.0 +} diff --git a/src/components/charts/shared/statisticalOverlays.ts b/src/components/charts/shared/statisticalOverlays.ts index b385d3ecc..32cddbb91 100644 --- a/src/components/charts/shared/statisticalOverlays.ts +++ b/src/components/charts/shared/statisticalOverlays.ts @@ -15,6 +15,7 @@ import { getMax, getMinMax } from "./minMax" */ import { darkenColor, lightenColor } from "./colorManipulation" +import { fitLinearForForecast, forecastIntervalStats, confidenceZScore } from "./leastSquaresRegression" // ── Config types ─────────────────────────────────────────────────────── @@ -464,28 +465,10 @@ function buildAutoForecast( if (points.length >= 3) { const n = points.length - let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0 - for (const [x, y] of points) { - sumX += x; sumY += y; sumXX += x * x; sumXY += x * y - } - const det = n * sumXX - sumX * sumX - if (Math.abs(det) > 1e-12) { - const slope = (n * sumXY - sumX * sumY) / det - const intercept = (sumY - slope * sumX) / n - const predict = (x: number) => intercept + slope * x - - // Residual standard error - const residuals = points.map(([x, y]) => y - predict(x)) - const sse = residuals.reduce((s, r) => s + r * r, 0) - const se = Math.sqrt(sse / Math.max(n - 2, 1)) - - const meanX = points.reduce((s, p) => s + p[0], 0) / n - const ssX = points.reduce((s, p) => s + (p[0] - meanX) ** 2, 0) - - const z = confidence >= 0.99 ? 2.576 - : confidence >= 0.95 ? 1.96 - : confidence >= 0.9 ? 1.645 - : 1.0 + const predict = fitLinearForForecast(points) + if (predict) { + const { se, meanX, ssX } = forecastIntervalStats(points, predict) + const z = confidenceZScore(confidence) const allX = data.map((d) => d[xAccessor] as number).filter((v) => v != null && isFinite(v)) const xMax = getMax(allX) diff --git a/src/components/charts/shared/styleRules.ts b/src/components/charts/shared/styleRules.ts index 71ee2b6a1..972f99817 100644 --- a/src/components/charts/shared/styleRules.ts +++ b/src/components/charts/shared/styleRules.ts @@ -362,7 +362,11 @@ export function styleRulesToXYStyle( /** * Ordinal-flavored {@link makeStyleRuleStyleFn}: the context is * `{ value: valueAccessor(d), category }` — the bar/piece value plus the - * stack/group key. Kept as sugar for the ordinal server config. + * stack/group key. Public API sugar mirroring {@link styleRulesToXYStyle}; + * unlike that XY sibling (used internally by `serverChartConfigsXY.ts`), the + * ordinal server config (`serverChartConfigsOrdinal.ts`) resolves colorBy + * and style rules together in its own `buildBarPieceStyle` rather than + * calling this — kept for external callers building an ordinal `pieceStyle`. */ export function styleRulesToPieceStyle( rules: ReadonlyArray | undefined, diff --git a/src/components/charts/shared/useChartLegend.ts b/src/components/charts/shared/useChartLegend.ts index 6a6281311..1ff180f2b 100644 --- a/src/components/charts/shared/useChartLegend.ts +++ b/src/components/charts/shared/useChartLegend.ts @@ -15,6 +15,24 @@ import { } from "../../legendLayout" import type { Datum } from "./datumTypes" +/** + * Distinct string-coerced `colorBy` values across `data`, in first-seen order. + * Shared by chart setup hooks that need the category list for a color scale + * or legend before any scale/legend object is built. + */ +export function distinctCategories( + data: readonly Datum[], + colorBy: Accessor | undefined +): string[] { + if (!colorBy) return [] + const vals = new Set() + for (const d of data) { + const v = typeof colorBy === "function" ? colorBy(d) : d[colorBy] + if (v != null) vals.add(String(v)) + } + return Array.from(vals) +} + /** * Hook to create a legend and compute margins with legend-aware adjustment. * Consolidates the shouldShowLegend / createLegend / margin merge / right-margin @@ -75,12 +93,7 @@ export function useChartLegendAndMargin({ const legendCategories = useMemo(() => { if (!shouldResolveCategories) return [] if (categories !== undefined) return categories - const vals = new Set() - for (const d of data) { - const v = typeof colorBy === "function" ? colorBy(d) : d[colorBy as string] - if (v != null) vals.add(String(v)) - } - return Array.from(vals) + return distinctCategories(data, colorBy) }, [categories, colorBy, data, shouldResolveCategories]) useLinkedChartCategories(linkedCategoryRegistryActive && colorBy ? legendCategories : []) @@ -245,7 +258,11 @@ export function useLegendInteraction( return { isActive: true, predicate: (d: Datum) => { - const val = colorField ? d[colorField] : typeof colorBy === "function" ? colorBy(d) : null + const raw = colorField ? d[colorField] : typeof colorBy === "function" ? colorBy(d) : null + // Legend labels are String(v)-coerced (useChartSetup's category + // extraction), so a non-string colorBy field (number, boolean) + // must be coerced the same way or it never matches. + const val = typeof raw === "string" ? raw : String(raw ?? "") return val === highlightedCategory } } @@ -255,7 +272,8 @@ export function useLegendInteraction( return { isActive: true, predicate: (d: Datum) => { - const val = colorField ? d[colorField] : typeof colorBy === "function" ? colorBy(d) : null + const raw = colorField ? d[colorField] : typeof colorBy === "function" ? colorBy(d) : null + const val = typeof raw === "string" ? raw : String(raw ?? "") return isolatedCategories.has(val) } } diff --git a/src/components/charts/shared/useChartSetup.ts b/src/components/charts/shared/useChartSetup.ts index d9eb8b843..e3da61de6 100644 --- a/src/components/charts/shared/useChartSetup.ts +++ b/src/components/charts/shared/useChartSetup.ts @@ -20,7 +20,7 @@ import type { Datum } from "./datumTypes" import { useCallback, useMemo, useState } from "react" import type { AxisChromeInput } from "../../legendLayout" -import { useColorScale, useChartSelection, useChartLegendAndMargin, useLegendInteraction, useThemeCategorical, DEFAULT_COLOR, getCrosshairProps, resolveMobileInteraction } from "./hooks" +import { useColorScale, useChartSelection, useChartLegendAndMargin, useLegendInteraction, useThemeCategorical, DEFAULT_COLOR, getCrosshairProps, resolveMobileInteraction, distinctCategories } from "./hooks" import type { LegendInteractionMode, LegendPosition } from "./hooks" import { useCategoryColors } from "../../CategoryColors" import { createColorScale, STREAMING_PALETTE } from "./colorUtils" @@ -152,7 +152,7 @@ export interface ChartSetupResult { /** If non-null, the HOC should return this element (loading or empty state) */ earlyReturn: ReactElement | null /** Props to spread into the stream frame for legend behavior */ - legendBehaviorProps: Datum + legendBehaviorProps: Record /** Crosshair props to spread into StreamXYFrame when linkedHover mode is "x-position" */ crosshairProps: { linkedCrosshairName: string; linkedCrosshairSourceId: string } | undefined /** @@ -246,7 +246,7 @@ export function useChartSetup(input: ChartSetupInput): ChartSetupResult { }, []) // ── Selection hooks (always called) ──────────────────────────────────── - const colorByField = typeof input.colorBy === "string" ? input.colorBy : undefined + const colorByField = typeof colorBy === "string" ? colorBy : undefined const resolvedMobileInteraction = useMemo( () => resolveMobileInteraction(mobileInteraction, { width, mobileSemantics }), [mobileInteraction, width, mobileSemantics], @@ -272,15 +272,10 @@ export function useChartSetup(input: ChartSetupInput): ChartSetupResult { const colorScale = useColorScale(safeData, colorBy, colorScheme) // ── Category extraction ──────────────────────────────────────────────── - const allCategories = useMemo(() => { - if (!colorBy) return [] - const vals = new Set() - for (const d of safeData as Datum[]) { - const v = typeof colorBy === "function" ? colorBy(d) : d[colorBy as string] - if (v != null) vals.add(String(v)) - } - return Array.from(vals) - }, [safeData, colorBy]) + const allCategories = useMemo( + () => distinctCategories(safeData as Datum[], colorBy), + [safeData, colorBy] + ) const activeCategories = useMemo(() => { if (isPushMode && frameCategories.length > 0) return frameCategories diff --git a/src/components/charts/shared/useNetworkChartSetup.ts b/src/components/charts/shared/useNetworkChartSetup.ts index 67ff36b91..edcfb5758 100644 --- a/src/components/charts/shared/useNetworkChartSetup.ts +++ b/src/components/charts/shared/useNetworkChartSetup.ts @@ -46,6 +46,7 @@ import { useLegendInteraction, useThemeCategorical, resolveMobileInteraction, + distinctCategories, } from "./hooks" import type { LegendInteractionMode, LegendPosition, LegendInteractionState } from "./hooks" import { DEFAULT_COLORS, resolveCategoricalPalette } from "./colorUtils" @@ -291,20 +292,19 @@ export function useNetworkChartSetup(() => { + // Array colorScheme passes through by reference (no copy) — a consumer + // test asserts identity here, and resolveCategoricalPalette's own array + // branch (colorUtils.ts) already returns the input array unchanged, so + // spreading it below would needlessly break that identity. if (Array.isArray(colorScheme) && colorScheme.length > 0) return colorScheme return [...resolveCategoricalPalette(colorScheme, themeCategorical, DEFAULT_COLORS)] }, [colorScheme, themeCategorical]) // ── Categories for legend interaction ─────────────────────────── - const allCategories = useMemo(() => { - if (!colorBy) return [] - const vals = new Set() - for (const d of safeNodes) { - const v = typeof colorBy === "function" ? colorBy(d) : d[colorBy as string] - if (v != null) vals.add(String(v)) - } - return Array.from(vals) - }, [safeNodes, colorBy]) + const allCategories = useMemo( + () => distinctCategories(safeNodes, colorBy), + [safeNodes, colorBy] + ) const legendState = useLegendInteraction(legendInteraction, colorBy, allCategories) @@ -335,7 +335,7 @@ export function useNetworkChartSetup { + const normalizedLinkedBrush = typeof linkedBrush === "string" + ? linkedBrush + : linkedBrush + ? { name: linkedBrush.name, xField: linkedBrush.rField } + : undefined + return normalizeLinkedBrush(normalizedLinkedBrush) + }, [linkedBrush]) const rFieldStr = typeof valueAccessor === "string" ? valueAccessor : "value" const brushHook = useBrushSelection({ diff --git a/src/components/charts/shared/validateProps.ts b/src/components/charts/shared/validateProps.ts index 482c02229..6f94762fc 100644 --- a/src/components/charts/shared/validateProps.ts +++ b/src/components/charts/shared/validateProps.ts @@ -8,13 +8,18 @@ import type { Datum } from "./datumTypes" * Components with dataShape "none" intentionally skip data validation. */ -import { validateArrayData } from "./validateChartData" -import { validateObjectData } from "./validateChartData" -import { validateNetworkData } from "./validateChartData" +import { + validateArrayData, + validateObjectData, + validateNetworkData, +} from "./validateChartData" import { VALIDATION_MAP } from "./validationMap" +import type { PropType, DataShape } from "./chartSpecCore" +import { closestMatch } from "./stringDistance" // Re-export for external consumers (diagnoseConfig, chartConfig, etc.) export { VALIDATION_MAP } +export type { PropType, DataShape } // --------------------------------------------------------------------------- // Types @@ -25,22 +30,11 @@ export interface ValidationResult { errors: string[] } -export type PropType = - | "string" - | "number" - | "boolean" - | "array" - | "object" - | "function" - | "null" - export interface PropDef { type: PropType | PropType[] enum?: readonly string[] } -type DataShape = "array" | "object" | "network" | "realtime" | "none" - export interface ComponentSpec { /** Props that must be present */ required: string[] @@ -113,8 +107,6 @@ function validateGaugeThresholds(props: Datum): string[] { return errors } -import { closestMatch } from "./stringDistance" - // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -183,7 +175,7 @@ export function validateProps( for (const [key, value] of Object.entries(props)) { if (value === undefined || value === null) continue const def = spec.props[key] - if (!def) continue // unknown prop — checked in step 5 + if (!def) continue // unknown prop — checked in step 4 // Type check if (!checkType(value, def.type)) { @@ -258,10 +250,9 @@ export function validateProps( accessors: nodeAccessors, }) if (dataError) errors.push(dataError) - } else if (spec.dataShape === "none") { - // Value-only components such as GaugeChart have no data prop to validate. } - // realtime charts: no data validation (ref-based push API) + // "none" (e.g. GaugeChart's value-only props) and "realtime" (ref-based + // push API) data shapes have no data prop to validate — no branch needed. if (componentName === "GaugeChart") { errors.push(...validateGaugeThresholds(props)) diff --git a/src/components/charts/shared/withChartWrapper.test.tsx b/src/components/charts/shared/withChartWrapper.test.tsx index 2826e3558..93d0e5203 100644 --- a/src/components/charts/shared/withChartWrapper.test.tsx +++ b/src/components/charts/shared/withChartWrapper.test.tsx @@ -3,7 +3,6 @@ import { render, screen } from "@testing-library/react" import * as React from "react" import { SafeRender, - warnDataShape, warnMissingField, renderEmptyState, renderLoadingState, @@ -41,29 +40,6 @@ describe("SafeRender", () => { expect(screen.getByTestId("child")).toBeTruthy() }) - it("shows the error message even when chartProps are passed", () => { - // chartProps is retained for back-compat but no longer threads - // diagnoseConfig output into the error fallback. Importing the full - // chartSpecs validation map into every subpath bundle (for a fallback - // that only fires on render-throw) wasn't worth ~7KB gz per subpath. - // Diagnostics remain available via `npx semiotic-ai --doctor` or - // `diagnoseConfig` from `semiotic/utils`. - const badProps = { data: [], width: 600, height: 400 } - - const { container } = render( - - - - ) - - const alert = screen.getByRole("alert") - expect(alert).toBeTruthy() - expect(alert.textContent).toContain("render failed") - // No diagnostic hint should appear — the integration was removed. - const hintPanel = container.querySelector("[data-testid='semiotic-diagnostic-hint']") - expect(hintPanel).toBeNull() - }) - it("shows error without diagnostic hint when no props are passed", () => { const { container } = render( @@ -84,87 +60,6 @@ describe("SafeRender", () => { ) expect(hintDivs).toHaveLength(0) }) - - it("shows error without diagnostic hint when props are valid (diagnoseConfig ok)", () => { - // Props that pass diagnoseConfig successfully — valid data with correct fields - const goodProps = { - data: [{ x: 1, y: 2 }], - xAccessor: "x", - yAccessor: "y", - width: 600, - height: 400, - } - - const { container } = render( - - - - ) - - const alert = screen.getByRole("alert") - expect(alert.textContent).toContain("unexpected error") - // diagnoseConfig should return ok: true, so no diagnostic hint - const monoDivs = container.querySelectorAll("div[style*='monospace']") - const hintDivs = Array.from(monoDivs).filter( - (el) => el.textContent !== "LineChart" - ) - expect(hintDivs).toHaveLength(0) - }) -}) - -// --------------------------------------------------------------------------- -// warnDataShape -// --------------------------------------------------------------------------- - -describe("warnDataShape", () => { - it("warns when data keys do not match expected keys (dev mode)", () => { - const spy = vi.spyOn(console, "warn").mockImplementation(() => {}) - - warnDataShape( - "BarChart", - [{ foo: 1, bar: 2 }], - ["category", "value"], - "Did you forget categoryAccessor?" - ) - - // IS_DEV is true in test env (NODE_ENV !== "production") - expect(spy).toHaveBeenCalledOnce() - expect(spy.mock.calls[0][0]).toContain("BarChart") - expect(spy.mock.calls[0][0]).toContain("foo, bar") - expect(spy.mock.calls[0][0]).toContain("category, value") - expect(spy.mock.calls[0][0]).toContain("Did you forget categoryAccessor?") - }) - - it("does not warn when at least one expected key is present", () => { - const spy = vi.spyOn(console, "warn").mockImplementation(() => {}) - - warnDataShape( - "BarChart", - [{ category: "A", extra: 1 }], - ["category", "value"], - "hint" - ) - - expect(spy).not.toHaveBeenCalled() - }) - - it("does not warn for empty data", () => { - const spy = vi.spyOn(console, "warn").mockImplementation(() => {}) - warnDataShape("BarChart", [], ["category"], "hint") - expect(spy).not.toHaveBeenCalled() - }) - - it("does not warn for undefined data", () => { - const spy = vi.spyOn(console, "warn").mockImplementation(() => {}) - warnDataShape("BarChart", undefined, ["category"], "hint") - expect(spy).not.toHaveBeenCalled() - }) - - it("does not warn when first element is not an object", () => { - const spy = vi.spyOn(console, "warn").mockImplementation(() => {}) - warnDataShape("BarChart", [42], ["category"], "hint") - expect(spy).not.toHaveBeenCalled() - }) }) // --------------------------------------------------------------------------- diff --git a/src/components/charts/shared/withChartWrapper.tsx b/src/components/charts/shared/withChartWrapper.tsx index 5e7f5d2cc..b21b31d72 100644 --- a/src/components/charts/shared/withChartWrapper.tsx +++ b/src/components/charts/shared/withChartWrapper.tsx @@ -10,10 +10,6 @@ interface SafeRenderProps { componentName: string width: number height: number - /** @deprecated Retained for back-compat; no longer threaded into the - * error boundary. Use `npx semiotic-ai --doctor` or import - * `diagnoseConfig` from `semiotic/utils` for validation diagnostics. */ - chartProps?: Record children: React.ReactNode } @@ -188,25 +184,3 @@ export function warnMissingField( `[semiotic] ${componentName}: ${accessorName} "${accessorValue}" not found in data. Available keys: ${available}` ) } - -/** Warn if data looks like the wrong shape for this chart type */ -export function warnDataShape( - componentName: string, - data: Array | undefined, - expectedKeys: string[], - hint: string -): void { - if (!IS_DEV) return - if (!data || data.length === 0) return - - const sample = data[0] - if (!sample || typeof sample !== "object") return - - const keys = Object.keys(sample) - const hasAny = expectedKeys.some(k => keys.includes(k)) - if (hasAny) return - - console.warn( - `[semiotic] ${componentName}: data[0] has keys [${keys.join(", ")}] but none of the expected keys [${expectedKeys.join(", ")}]. ${hint}` - ) -} diff --git a/src/components/charts/value/thresholdSparkline.ts b/src/components/charts/value/thresholdSparkline.ts index b6d519e9f..220c55862 100644 --- a/src/components/charts/value/thresholdSparkline.ts +++ b/src/components/charts/value/thresholdSparkline.ts @@ -87,7 +87,9 @@ export function buildSparklinePath( const innerW = Math.max(0, opts.width - padding * 2) const innerH = Math.max(0, opts.height - padding * 2) - const stepX = values.length === 1 ? 0 : innerW / (values.length - 1) + // values.length is always >= 2 here — the 0- and 1-length cases already + // returned above. + const stepX = innerW / (values.length - 1) const points: SparklinePoint[] = [] let line = "" diff --git a/src/components/charts/xy/BumpChart.tsx b/src/components/charts/xy/BumpChart.tsx index b656eba13..e0dd3f33d 100644 --- a/src/components/charts/xy/BumpChart.tsx +++ b/src/components/charts/xy/BumpChart.tsx @@ -315,11 +315,11 @@ function BumpLabel({ textAnchor={side === "end" ? "start" : "end"} fill={color} fillOpacity={dimmed ? 0.16 : 1} - fontWeight={highlighted ? 650 : 450} + fontWeight={highlighted ? 650 : 450} fontSize={12} style={{ pointerEvents: "none", fontFamily: "var(--semiotic-font-family, sans-serif)", - fontSize: "var(--semiotic-axis-label-font-size, 11px)", + fontSize: "var(--semiotic-axis-label-font-size, 12px)", ...customStyle, }} > diff --git a/src/components/data/auditData.ts b/src/components/data/auditData.ts index 32130b202..fd1a90047 100644 --- a/src/components/data/auditData.ts +++ b/src/components/data/auditData.ts @@ -13,6 +13,7 @@ import { type NumericFieldRole, type NumericRequirement, } from "./numericContracts" +import { quantile } from "./numericFieldProfiler" const MAX_AFFECTED_ROWS = 5 @@ -212,16 +213,6 @@ function observe( return observation } -function quantile(sorted: ReadonlyArray, p: number): number | undefined { - if (sorted.length === 0) return undefined - const index = (sorted.length - 1) * p - const lower = Math.floor(index) - const upper = Math.ceil(index) - if (lower === upper) return sorted[lower] - const weight = index - lower - return sorted[lower] * (1 - weight) + sorted[upper] * weight -} - function requirementSet( field: NumericFieldContract, props: Datum, diff --git a/src/components/data/numericFieldProfiler.ts b/src/components/data/numericFieldProfiler.ts index c07581f14..caed3e3c5 100644 --- a/src/components/data/numericFieldProfiler.ts +++ b/src/components/data/numericFieldProfiler.ts @@ -83,7 +83,8 @@ function observeField(data: ReadonlyArray, field: string): FieldObservati return out } -function quantile(sorted: ReadonlyArray, p: number): number | undefined { +/** Linear-interpolation quantile over a pre-sorted array. */ +export function quantile(sorted: ReadonlyArray, p: number): number | undefined { if (sorted.length === 0) return undefined const index = (sorted.length - 1) * p const lower = Math.floor(index) diff --git a/src/components/store/createStore.tsx b/src/components/store/createStore.tsx index 39cb3aeac..ef3d3cda0 100644 --- a/src/components/store/createStore.tsx +++ b/src/components/store/createStore.tsx @@ -122,7 +122,8 @@ function createSource( } } -function hasOwnEnumerableKey(value: object): boolean { +/** True if `value` has at least one of its own enumerable keys. */ +export function hasOwnEnumerableKey(value: object): boolean { for (const key in value) { if (Object.prototype.hasOwnProperty.call(value, key)) return true } diff --git a/src/components/store/useSelection.ts b/src/components/store/useSelection.ts index 49c1d57db..123248814 100644 --- a/src/components/store/useSelection.ts +++ b/src/components/store/useSelection.ts @@ -8,6 +8,7 @@ import { type FieldSelection, type SelectionStoreState } from "./SelectionStore" +import { hasOwnEnumerableKey } from "./createStore" // Re-export crosshair store for convenience export { useCrosshairPosition, setCrosshairPosition, clearCrosshairPosition } from "./LinkedCrosshairStore" @@ -302,13 +303,6 @@ export function useBrushSelection(options: UseBrushSelectionOptions): UseBrushSe return { brushInteraction, predicate, isActive, clear } } -function hasOwnEnumerableKey(value: object): boolean { - for (const key in value) { - if (Object.prototype.hasOwnProperty.call(value, key)) return true - } - return false -} - // ── useFilteredData ──────────────────────────────────────────────────────── /** diff --git a/src/components/stream/GeoSVGOverlay.tsx b/src/components/stream/GeoSVGOverlay.tsx index 4f4fb9041..d5de3f57e 100644 --- a/src/components/stream/GeoSVGOverlay.tsx +++ b/src/components/stream/GeoSVGOverlay.tsx @@ -210,6 +210,7 @@ export function GeoSVGOverlay(props: GeoSVGOverlayProps) { textAnchor="middle" fontWeight="bold" fill="var(--semiotic-text, #333)" + fontSize={14} className="semiotic-chart-title" style={{ userSelect: "none", diff --git a/src/components/stream/NetworkSVGOverlay.tsx b/src/components/stream/NetworkSVGOverlay.tsx index eff9cda0b..4982f6a58 100644 --- a/src/components/stream/NetworkSVGOverlay.tsx +++ b/src/components/stream/NetworkSVGOverlay.tsx @@ -274,6 +274,7 @@ export function NetworkSVGOverlay(props: NetworkSVGOverlayProps) { textAnchor="middle" fontWeight={600} fill="currentColor" + fontSize={14} className="semiotic-chart-title" style={{ fontSize: "var(--semiotic-title-font-size, 14px)" }} > diff --git a/src/components/stream/OrdinalSVGOverlay.tsx b/src/components/stream/OrdinalSVGOverlay.tsx index 3bec234fb..e88904576 100644 --- a/src/components/stream/OrdinalSVGOverlay.tsx +++ b/src/components/stream/OrdinalSVGOverlay.tsx @@ -435,6 +435,11 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { // override via the var rather than fighting inline specificity. const tickFontStyle = { fontSize: "var(--semiotic-tick-font-size, 12px)" } const axisLabelFontStyle = { fontSize: "var(--semiotic-axis-label-font-size, 12px)" } + // Plain `fontSize` presentation-attribute fallbacks — see + // SVGOverlay for the rationale (a consumer with no CSS engine + // over the SVG can't resolve the `var()` above). + const TICK_FONT_SIZE = 12 + const AXIS_LABEL_FONT_SIZE = 12 return ( {isHorizontal ? ( @@ -452,6 +457,7 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { textAnchor="end" dominantBaseline="middle" fill="var(--semiotic-text-secondary, #666)" + fontSize={TICK_FONT_SIZE} className="semiotic-axis-tick" style={{ userSelect: "none", ...tickFontStyle }} > @@ -471,6 +477,7 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { textAnchor="middle" fill="var(--semiotic-text, #333)" transform={`rotate(-90, ${-margin.left + 15}, ${height / 2})`} + fontSize={AXIS_LABEL_FONT_SIZE} className="semiotic-axis-label" style={{ userSelect: "none", ...axisLabelFontStyle }} > @@ -501,6 +508,7 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { y={18} textAnchor={anchor} fill="var(--semiotic-text-secondary, #666)" + fontSize={TICK_FONT_SIZE} className="semiotic-axis-tick" style={{ userSelect: "none", ...tickFontStyle }} > @@ -515,6 +523,7 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { y={height + 40} textAnchor="middle" fill="var(--semiotic-text, #333)" + fontSize={AXIS_LABEL_FONT_SIZE} className="semiotic-axis-label" style={{ userSelect: "none", ...axisLabelFontStyle }} > @@ -541,6 +550,7 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { y={18} textAnchor="middle" fill="var(--semiotic-text-secondary, #666)" + fontSize={TICK_FONT_SIZE} className="semiotic-axis-tick" style={{ userSelect: "none", ...tickFontStyle }} > @@ -559,6 +569,7 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { y={height + 40} textAnchor="middle" fill="var(--semiotic-text, #333)" + fontSize={AXIS_LABEL_FONT_SIZE} className="semiotic-axis-label" style={{ userSelect: "none", ...axisLabelFontStyle }} > @@ -578,6 +589,7 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { textAnchor="end" dominantBaseline="middle" fill="var(--semiotic-text-secondary, #666)" + fontSize={TICK_FONT_SIZE} className="semiotic-axis-tick" style={{ userSelect: "none", ...tickFontStyle }} > @@ -592,6 +604,7 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { textAnchor="middle" fill="var(--semiotic-text, #333)" transform={`rotate(-90, ${-margin.left + 15}, ${height / 2})`} + fontSize={AXIS_LABEL_FONT_SIZE} className="semiotic-axis-label" style={{ userSelect: "none", ...axisLabelFontStyle }} > @@ -622,6 +635,7 @@ export function OrdinalSVGOverlay(props: OrdinalSVGOverlayProps) { textAnchor="middle" fontWeight="bold" fill="var(--semiotic-text, #333)" + fontSize={14} className="semiotic-chart-title" style={{ userSelect: "none", fontSize: "var(--semiotic-title-font-size, 14px)" }} > diff --git a/src/components/stream/SVGOverlay.axisCustomization.test.tsx b/src/components/stream/SVGOverlay.axisCustomization.test.tsx index 4f01cf189..17dd58ef1 100644 --- a/src/components/stream/SVGOverlay.axisCustomization.test.tsx +++ b/src/components/stream/SVGOverlay.axisCustomization.test.tsx @@ -7,7 +7,12 @@ * target individual axes via CSS without `!important`. * 3. Inline `font-size` references `var(--semiotic-tick-font-size, …)` / * `var(--semiotic-axis-label-font-size, …)` instead of hardcoded - * numbers, so a parent setting the CSS variable wins via cascade. + * numbers, so a parent setting the CSS variable wins via cascade. Each + * `` also carries a plain `font-size` presentation attribute + * mirroring the var's own fallback number — strictly lower cascade + * priority than the inline style, so it never overrides the CSS-var + * path in a browser, but it keeps a sane size for consumers that don't + * run a CSS engine over the SVG at all. */ import * as React from "react" import { render } from "@testing-library/react" @@ -336,10 +341,17 @@ describe("font-size CSS variables", () => { expect(style).toContain("var(--semiotic-axis-label-font-size, 12px)") }) - it("does not emit a hardcoded font-size presentation attribute on tick text", () => { - // The whole point of the refactor: presentation-attribute-style or - // numeric font-size would beat external CSS in the cascade. Tick - // should rely on inline style → CSS var → cascade override. + it("also emits a plain font-size attribute as a non-CSS-engine fallback", () => { + // A presentation attribute is strictly lower-priority than the inline + // `style` attribute in the CSS cascade (SVG2: presentation attributes + // act as author-level rules with zero specificity, beaten by any style + // sheet mechanism including inline style) — so it can never override the + // CSS-var cascade override in a real browser. But a consumer with no CSS + // engine over the SVG (a `style`-stripping sanitizer, the Figma plugin's + // SVG importer, static rasterizers like resvg) can't resolve + // `var(--semiotic-tick-font-size, ...)` and would otherwise silently + // inherit the host document's font-size. The plain attribute is that + // consumer's fallback. const { container } = render( { ) const tickText = container.querySelector("text.semiotic-axis-tick") as SVGTextElement | null expect(tickText).toBeTruthy() - expect(tickText!.getAttribute("font-size")).toBeNull() + expect(tickText!.getAttribute("font-size")).toBe("12") }) it("landmark ticks use a calc() that adds 1px to the base CSS var", () => { diff --git a/src/components/stream/SVGOverlay.tsx b/src/components/stream/SVGOverlay.tsx index 45418f1b5..bd81347f7 100644 --- a/src/components/stream/SVGOverlay.tsx +++ b/src/components/stream/SVGOverlay.tsx @@ -683,6 +683,16 @@ export function SVGOverlay(props: SVGOverlayProps) { const tickFontStyle = { fontSize: "var(--semiotic-tick-font-size, 12px)" } const tickFontStyleLandmark = { fontSize: "calc(var(--semiotic-tick-font-size, 12px) + 1px)" } const axisLabelFontStyle = { fontSize: "var(--semiotic-axis-label-font-size, 12px)" } + // Plain `fontSize` presentation-attribute fallbacks alongside the + // `style` above. A real browser's `style` attribute always wins + // over a presentation attribute, so live CSS-var cascade + // overrides are unaffected — but a consumer that doesn't run a + // CSS engine over the SVG (a sanitizer, the Figma plugin's SVG + // importer, static rasterization) can't resolve `var(...)` and + // would otherwise silently inherit the host document's font-size. + const TICK_FONT_SIZE = 12 + const LANDMARK_TICK_FONT_SIZE = 13 + const AXIS_LABEL_FONT_SIZE = 12 const bottomTickAnchorMode = bottomAxis?.tickAnchor const leftTickAnchorMode = leftAxis?.tickAnchor // Pre-compute the edge pixels for each axis so the tick-render @@ -723,6 +733,7 @@ export function SVGOverlay(props: SVGOverlayProps) { )} fontWeight={isLandmark ? 600 : 400} fill={tickColor} + fontSize={isLandmark ? LANDMARK_TICK_FONT_SIZE : TICK_FONT_SIZE} className="semiotic-axis-tick" style={{ userSelect: "none", ...(isLandmark ? tickFontStyleLandmark : tickFontStyle) }} transform={shouldRotateBottom ? "rotate(-45)" : undefined} @@ -743,6 +754,7 @@ export function SVGOverlay(props: SVGOverlayProps) { y={bottomAxisLabelY} textAnchor="middle" fill={labelColor} + fontSize={AXIS_LABEL_FONT_SIZE} className="semiotic-axis-label" style={{ userSelect: "none", ...axisLabelFontStyle }} > @@ -779,6 +791,7 @@ export function SVGOverlay(props: SVGOverlayProps) { )} fontWeight={isLandmark ? 600 : 400} fill={tickColor} + fontSize={isLandmark ? LANDMARK_TICK_FONT_SIZE : TICK_FONT_SIZE} className="semiotic-axis-tick" style={{ userSelect: "none", ...(isLandmark ? tickFontStyleLandmark : tickFontStyle) }} > @@ -801,6 +814,7 @@ export function SVGOverlay(props: SVGOverlayProps) { textAnchor="middle" fill={labelColor} transform={`rotate(-90, ${-leftAxisLabelMargin + 15}, ${height / 2})`} + fontSize={AXIS_LABEL_FONT_SIZE} className="semiotic-axis-label" style={{ userSelect: "none", ...axisLabelFontStyle }} > @@ -844,6 +858,7 @@ export function SVGOverlay(props: SVGOverlayProps) { )} fontWeight={isLandmark ? 600 : 400} fill={tickColor} + fontSize={isLandmark ? LANDMARK_TICK_FONT_SIZE : TICK_FONT_SIZE} className="semiotic-axis-tick" style={{ userSelect: "none", ...(isLandmark ? tickFontStyleLandmark : tickFontStyle) }} > @@ -864,6 +879,7 @@ export function SVGOverlay(props: SVGOverlayProps) { textAnchor="middle" fill={labelColor} transform={`rotate(90, ${width + rightAxisLabelMargin - 15}, ${height / 2})`} + fontSize={AXIS_LABEL_FONT_SIZE} className="semiotic-axis-label" style={{ userSelect: "none", ...axisLabelFontStyle }} > @@ -964,6 +980,7 @@ export function SVGOverlay(props: SVGOverlayProps) { textAnchor="middle" fontWeight="bold" fill="var(--semiotic-text, #333)" + fontSize={14} className="semiotic-chart-title" style={{ userSelect: "none", fontSize: "var(--semiotic-title-font-size, 14px)" }} > diff --git a/src/components/stream/accessorUtils.ts b/src/components/stream/accessorUtils.ts index 8cce85e7b..4986ef5a5 100644 --- a/src/components/stream/accessorUtils.ts +++ b/src/components/stream/accessorUtils.ts @@ -1,3 +1,5 @@ +import type { Datum } from "../charts/shared/datumTypes" + // ── Accessor resolution ──────────────────────────────────────────────── /** @@ -64,3 +66,31 @@ export function resolveStringAccessor>( if (fallback) return (d: T) => String(d[fallback]) return undefined } + +/** + * Resolve a `nodeLabel` accessor (string key or function) into a label + * function, or `null` when unset — the network layout plugins' shared + * convention for "no label configured" (as opposed to `resolveStringAccessor`'s + * `undefined`). A string accessor falls back to `d.id` when the field is + * missing/empty. Shared by the chord/force/sankey layout plugins; hierarchy + * layouts use their own `resolveLabelFn` in `layouts/hierarchyUtils.ts` + * instead, since a hierarchy scene node nests the original datum under + * `.data` and must unwrap it before calling a user accessor. + */ +export function resolveLabelFn( + nodeLabel: string | ((d: Datum) => string) | undefined +): ((d: Datum) => string) | null { + if (!nodeLabel) return null + if (typeof nodeLabel === "function") return nodeLabel + return (d: Datum) => d[nodeLabel] || d.id +} + +/** + * Resolve a d3-force-style edge endpoint (`source`/`target`) to its node id. + * Before a force/sankey/chord simulation resolves string ids into node + * object references, an edge endpoint is `string | { id: string }`; this + * reads the id either way. Shared by the chord/force/sankey layout plugins. + */ +export function resolveNodeRefId(ref: string | { id: string }): string { + return typeof ref === "string" ? ref : ref.id +} diff --git a/src/components/stream/layouts/chordLayoutPlugin.ts b/src/components/stream/layouts/chordLayoutPlugin.ts index c799cd7a1..812a701b5 100644 --- a/src/components/stream/layouts/chordLayoutPlugin.ts +++ b/src/components/stream/layouts/chordLayoutPlugin.ts @@ -1,5 +1,6 @@ import { chord, ribbon, type Chord } from "d3-chord" import { wrapWithDataHint } from "../devDataAccessWarning" +import { resolveLabelFn, resolveNodeRefId } from "../accessorUtils" import { arc, type DefaultArcObject } from "d3-shape" import { schemeCategory10 } from "../../charts/shared/colorPalettes" import type { @@ -58,10 +59,8 @@ export const chordLayoutPlugin: NetworkLayoutPlugin = { ) for (const edge of edges) { - const sourceId = - typeof edge.source === "string" ? edge.source : edge.source.id - const targetId = - typeof edge.target === "string" ? edge.target : edge.target.id + const sourceId = resolveNodeRefId(edge.source) + const targetId = resolveNodeRefId(edge.target) const si = nodeIndex.get(sourceId) const ti = nodeIndex.get(targetId) @@ -119,8 +118,8 @@ export const chordLayoutPlugin: NetworkLayoutPlugin = { for (const n of nodes) nodeMap.set(n.id, n) for (const edge of edges) { - const srcId = typeof edge.source === "string" ? edge.source : edge.source.id - const tgtId = typeof edge.target === "string" ? edge.target : edge.target.id + const srcId = resolveNodeRefId(edge.source) + const tgtId = resolveNodeRefId(edge.target) const srcNode = nodeMap.get(srcId) const tgtNode = nodeMap.get(tgtId) if (srcNode) edge.source = srcNode @@ -131,8 +130,8 @@ export const chordLayoutPlugin: NetworkLayoutPlugin = { // Build bidirectional edge lookup (chord may emit in either direction) const edgeLookup = new Map() for (const e of edges) { - const eSrc = typeof e.source === "string" ? e.source : e.source.id - const eTgt = typeof e.target === "string" ? e.target : e.target.id + const eSrc = resolveNodeRefId(e.source) + const eTgt = resolveNodeRefId(e.target) // Store by source\0target for bidirectional lookup (chord parallel edges are less common) edgeLookup.set(`${eSrc}\0${eTgt}`, e) } @@ -335,14 +334,6 @@ export const chordLayoutPlugin: NetworkLayoutPlugin = { } } -function resolveLabelFn( - nodeLabel: string | ((d: Datum) => string) | undefined -): ((d: Datum) => string) | null { - if (!nodeLabel) return null - if (typeof nodeLabel === "function") return nodeLabel - return (d: Datum) => d[nodeLabel] || d.id -} - function resolveValueAccessor( valueAccessor: string | ((d: Datum) => number) | undefined ): (d: Datum) => number { diff --git a/src/components/stream/layouts/forceLayoutPlugin.ts b/src/components/stream/layouts/forceLayoutPlugin.ts index 424022c05..46624bcf3 100644 --- a/src/components/stream/layouts/forceLayoutPlugin.ts +++ b/src/components/stream/layouts/forceLayoutPlugin.ts @@ -12,6 +12,7 @@ import { schemeCategory10 } from "../../charts/shared/colorPalettes" import { getMinMax } from "../../charts/shared/minMax" import { createSeededFrameRandom } from "../FrameRuntime" import { wrapWithDataHint } from "../devDataAccessWarning" +import { resolveLabelFn, resolveNodeRefId } from "../accessorUtils" import type { NetworkLayoutPlugin, NetworkPipelineConfig, @@ -180,8 +181,8 @@ export const forceLayoutPlugin: NetworkLayoutPlugin = { nodeById.set(node.id, node) } for (const edge of edges) { - const sourceId = typeof edge.source === "string" ? edge.source : edge.source.id - const targetId = typeof edge.target === "string" ? edge.target : edge.target.id + const sourceId = resolveNodeRefId(edge.source) + const targetId = resolveNodeRefId(edge.target) degreeById.set(sourceId, (degreeById.get(sourceId) ?? 0) + 1) degreeById.set(targetId, (degreeById.get(targetId) ?? 0) + 1) } @@ -194,8 +195,8 @@ export const forceLayoutPlugin: NetworkLayoutPlugin = { const linkForce = forceLink() .strength((d) => { const weight = (d as RealtimeEdge & { weight?: number }).weight - const sourceId = typeof d.source === "string" ? d.source : d.source.id - const targetId = typeof d.target === "string" ? d.target : d.target.id + const sourceId = resolveNodeRefId(d.source) + const targetId = resolveNodeRefId(d.target) const endpointDegree = Math.max( 1, Math.min(degreeById.get(sourceId) ?? 1, degreeById.get(targetId) ?? 1) @@ -277,8 +278,8 @@ export const forceLayoutPlugin: NetworkLayoutPlugin = { // Resolve edge source/target to id strings for d3-force linking const linkData = edges.map((e) => ({ ...e, - source: typeof e.source === "string" ? e.source : e.source.id, - target: typeof e.target === "string" ? e.target : e.target.id + source: resolveNodeRefId(e.source), + target: resolveNodeRefId(e.target) })) linkForce.links(linkData) @@ -477,8 +478,8 @@ function findNeighborPositions( const positions: Array<{ x: number; y: number }> = [] for (const edge of edges) { - const srcId = typeof edge.source === "string" ? edge.source : edge.source.id - const tgtId = typeof edge.target === "string" ? edge.target : edge.target.id + const srcId = resolveNodeRefId(edge.source) + const tgtId = resolveNodeRefId(edge.target) let neighborId: string | null = null if (srcId === nodeId) neighborId = tgtId @@ -551,14 +552,6 @@ function simpleHash(str: string): number { return Math.abs(hash) } -function resolveLabelFn( - nodeLabel: string | ((d: Datum) => string) | undefined -): ((d: Datum) => string) | null { - if (!nodeLabel) return null - if (typeof nodeLabel === "function") return nodeLabel - return (d: Datum) => d[nodeLabel] || d.id -} - /** * Build a function that returns a node radius. If `nodeSize` is a number, * use it directly. If it is a string accessor, look up `node.data[nodeSize]` diff --git a/src/components/stream/layouts/index.ts b/src/components/stream/layouts/index.ts index be613dc1e..dd631134d 100644 --- a/src/components/stream/layouts/index.ts +++ b/src/components/stream/layouts/index.ts @@ -8,7 +8,7 @@ import { orbitLayoutPlugin } from "./orbitLayoutPlugin" /** * Registry mapping network chart types to their layout plugins. */ -export const networkLayoutRegistry: Record = { +export const networkLayoutRegistry: Record = { sankey: sankeyLayoutPlugin, force: forceLayoutPlugin, chord: chordLayoutPlugin, diff --git a/src/components/stream/layouts/sankeyLayoutPlugin.ts b/src/components/stream/layouts/sankeyLayoutPlugin.ts index 71c8907da..7e2ee8441 100644 --- a/src/components/stream/layouts/sankeyLayoutPlugin.ts +++ b/src/components/stream/layouts/sankeyLayoutPlugin.ts @@ -9,6 +9,7 @@ import { import { schemeCategory10 } from "../../charts/shared/colorPalettes" import { areaLink, circularAreaLink } from "../../geometry/sankeyLinks" import { wrapWithDataHint } from "../devDataAccessWarning" +import { resolveLabelFn, resolveNodeRefId } from "../accessorUtils" import type { NetworkLayoutPlugin, NetworkPipelineConfig, @@ -58,10 +59,6 @@ interface GradientBezierEdge extends NetworkBezierEdge { } } -function getNodeId(node: SankeyNodeRef | string): string { - return typeof node === "string" ? node : node.id -} - /** * Sankey layout plugin — uses d3-sankey-circular for layout computation. * @@ -94,8 +91,8 @@ export const sankeyLayoutPlugin: NetworkLayoutPlugin = { const sankeyNodes = nodes.map((n) => ({ ...n })) const sankeyEdges = edges.map((e) => ({ ...e, - source: typeof e.source === "string" ? e.source : e.source.id, - target: typeof e.target === "string" ? e.target : e.target.id, + source: resolveNodeRefId(e.source), + target: resolveNodeRefId(e.target), value: Math.sqrt(Math.max(1, e.value || 1)) })) @@ -235,16 +232,16 @@ export const sankeyLayoutPlugin: NetworkLayoutPlugin = { if (e._edgeKey) { edgeMap.set(e._edgeKey, e) } else { - const eSrc = typeof e.source === "string" ? e.source : e.source.id - const eTgt = typeof e.target === "string" ? e.target : e.target.id + const eSrc = resolveNodeRefId(e.source) + const eTgt = resolveNodeRefId(e.target) edgeMap.set(`${eSrc}\0${eTgt}`, e) } } // Write computed positions back to original edges for (const se of sankeyEdges as SankeyComputedEdge[]) { - const sourceId = getNodeId(se.source) - const targetId = getNodeId(se.target) + const sourceId = resolveNodeRefId(se.source) + const targetId = resolveNodeRefId(se.target) const original = se._edgeKey ? edgeMap.get(se._edgeKey) @@ -504,10 +501,3 @@ export const sankeyLayoutPlugin: NetworkLayoutPlugin = { } } -function resolveLabelFn( - nodeLabel: string | ((d: Datum) => string) | undefined -): ((d: Datum) => string) | null { - if (!nodeLabel) return null - if (typeof nodeLabel === "function") return nodeLabel - return (d: Datum) => d[nodeLabel] || d.id -} diff --git a/src/components/stream/networkFramePaint.ts b/src/components/stream/networkFramePaint.ts index e92b77774..080daf397 100644 --- a/src/components/stream/networkFramePaint.ts +++ b/src/components/stream/networkFramePaint.ts @@ -9,7 +9,7 @@ import { networkEdgeRenderer } from "./renderers/networkEdgeRenderer" import { networkRectRenderer } from "./renderers/networkRectRenderer" import { networkCircleRenderer } from "./renderers/networkCircleRenderer" import { networkArcRenderer } from "./renderers/networkArcRenderer" -import { networkSymbolRenderer } from "./renderers/networkSymbolRenderer" +import { networkSymbolRenderer } from "./renderers/symbolCanvasRenderer" import { networkGlyphRenderer } from "./renderers/glyphCanvasRenderer" import { renderNetworkParticles, diff --git a/src/components/stream/ordinalSceneBuilders/pointScene.ts b/src/components/stream/ordinalSceneBuilders/pointScene.ts index 192a8ec03..cb5cdc223 100644 --- a/src/components/stream/ordinalSceneBuilders/pointScene.ts +++ b/src/components/stream/ordinalSceneBuilders/pointScene.ts @@ -1,33 +1,18 @@ import type { OrdinalSceneNode, OrdinalLayout } from "../ordinalTypes" import type { Style } from "../types" import type { Datum } from "../../charts/shared/datumTypes" -import { SYMBOL_SEQUENCE, type SymbolName } from "../symbolPath" +import { makeSymbolResolver, type SymbolName } from "../symbolPath" import type { OrdinalSceneContext } from "./types" /** - * Build a per-call category→shape resolver for the symbolBy channel, or null - * when no symbol accessor is set. Explicit `symbolMap` wins; unmapped categories - * auto-assign from SYMBOL_SEQUENCE in first-seen (deterministic) order. Mirrors - * the XY point scene builder so swarm/dot glyphs match Scatterplot's. + * Per-call category→shape resolver for the symbolBy channel, or null when no + * symbol accessor is set. Mirrors the XY point scene builder (both call the + * shared `makeSymbolResolver`) so swarm/dot glyphs match Scatterplot's. */ function makeShapeResolver(ctx: OrdinalSceneContext): ((d: Datum) => SymbolName) | null { const getSymbol = ctx.getSymbol if (!getSymbol) return null - const symbolMapCfg = ctx.config.symbolMap - const assign = new Map() - let seq = 0 - return (d: Datum) => { - const cat = String(getSymbol(d)) - const explicit = symbolMapCfg?.[cat] - if (explicit) return explicit - let s = assign.get(cat) - if (!s) { - s = SYMBOL_SEQUENCE[seq % SYMBOL_SEQUENCE.length] - seq++ - assign.set(cat, s) - } - return s - } + return makeSymbolResolver(getSymbol, ctx.config.symbolMap) } /** Emit a point or, when symbolBy is active, a glyph (size = πr²) at (px, py). */ diff --git a/src/components/stream/ordinalSceneBuilders/sceneBuilderMap.ts b/src/components/stream/ordinalSceneBuilders/sceneBuilderMap.ts index 392091e8f..7d4c2daa6 100644 --- a/src/components/stream/ordinalSceneBuilders/sceneBuilderMap.ts +++ b/src/components/stream/ordinalSceneBuilders/sceneBuilderMap.ts @@ -16,8 +16,11 @@ import { buildFunnelScene } from "./funnelScene" import { buildBarFunnelScene } from "./barFunnelScene" import { buildSwimlaneScene } from "./swimlaneScene" import type { SceneBuilderFn } from "./types" +import type { OrdinalChartType } from "../ordinalTypes" -export const ORDINAL_SCENE_BUILDERS: Record = { +// "custom" isn't listed — XYCustomChart/OrdinalCustomChart charts build their +// scene via a user-supplied layout function instead of a registry lookup. +export const ORDINAL_SCENE_BUILDERS: Partial> = { bar: buildBarScene, clusterbar: buildClusterBarScene, point: buildPointScene, diff --git a/src/components/stream/physics/CapacityQueueController.ts b/src/components/stream/physics/CapacityQueueController.ts index a9bbc65f7..fb56b0e69 100644 --- a/src/components/stream/physics/CapacityQueueController.ts +++ b/src/components/stream/physics/CapacityQueueController.ts @@ -1,6 +1,7 @@ /** FIFO capacity-queue controller: stateful region service riding StreamPhysicsFrame's live heartbeat. */ -import type { PhysicsBodyState, PhysicsColliderBodyFilter } from "./PhysicsKernel" +import type { PhysicsBodyState } from "./PhysicsKernel" +import { valueAtPath, matchesPhysicsBodyFilter } from "./PhysicsKernel" import type { PhysicsController, PhysicsControllerTickContext @@ -61,41 +62,6 @@ function quantile(sorted: readonly number[], probability: number): number { return sorted[lower] + (sorted[upper] - sorted[lower]) * fraction } -function valueAtPath(source: unknown, path: string): unknown { - if (!path) return undefined - let current = source - for (const part of path.split(".")) { - if (current == null || typeof current !== "object") return undefined - current = (current as Record)[part] - } - return current -} - -function bodyMatchesFilter( - body: PhysicsBodyState, - filter: PhysicsColliderBodyFilter | undefined -): boolean { - if (!filter) return true - if (typeof filter === "function") return filter(body) - - const value = valueAtPath(body, filter.property) - if ("equals" in filter && !Object.is(value, filter.equals)) return false - if ("notEquals" in filter && Object.is(value, filter.notEquals)) return false - if ( - filter.oneOf && - !filter.oneOf.some((candidate) => Object.is(value, candidate)) - ) { - return false - } - if ( - filter.notOneOf && - filter.notOneOf.some((candidate) => Object.is(value, candidate)) - ) { - return false - } - return true -} - /** FIFO region service with visit evidence, overflow staging, and live metrics. */ export function createCapacityQueueController( options: CapacityQueueControllerOptions @@ -340,7 +306,7 @@ export function createCapacityQueueController( for (const body of bodies) { bodyById.set(body.id, body) if (!inRegion(body.id, ctx.getRegionState)) continue - if (!bodyMatchesFilter(body, options.bodyFilter)) continue + if (!matchesPhysicsBodyFilter(body, options.bodyFilter)) continue const jobId = readCapacityJobId(body, options.jobKey) const candidates = presentByJob.get(jobId) ?? [] candidates.push(body) diff --git a/src/components/stream/physics/PhysicsKernel.ts b/src/components/stream/physics/PhysicsKernel.ts index d158e8fba..78bd9b089 100644 --- a/src/components/stream/physics/PhysicsKernel.ts +++ b/src/components/stream/physics/PhysicsKernel.ts @@ -203,6 +203,16 @@ function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) } +/** Pair two per-body restitution values — the less-bouncy of the two wins (conservative combining, avoids energy gain). */ +function pairRestitution(a: number | undefined, b: number | undefined, fallback: number): number { + return Math.min(a ?? fallback, b ?? fallback) +} + +/** Pair two per-body friction values (grippier of the two wins), clamped to the valid [0, 1] range. */ +function pairFriction(a: number | undefined, b: number | undefined, fallback: number): number { + return clamp(Math.max(a ?? fallback, b ?? fallback), 0, 1) +} + function signOrOne(value: number): number { return value < 0 ? -1 : 1 } @@ -313,8 +323,8 @@ function circleAabbCollision( const halfHeight = bh / 2 const closestX = clamp(cx, bx - halfWidth, bx + halfWidth) const closestY = clamp(cy, by - halfHeight, by + halfHeight) - let dx = cx - closestX - let dy = cy - closestY + const dx = cx - closestX + const dy = cy - closestY const distanceSquared = dx * dx + dy * dy if (distanceSquared > radius * radius) return null @@ -325,24 +335,10 @@ function circleAabbCollision( const top = Math.abs(cy - (by - halfHeight)) const bottom = Math.abs(cy - (by + halfHeight)) const min = Math.min(left, right, top, bottom) - if (min === left) { - dx = -1 - dy = 0 - return { nx: dx, ny: dy, penetration: radius + left } - } - if (min === right) { - dx = 1 - dy = 0 - return { nx: dx, ny: dy, penetration: radius + right } - } - if (min === top) { - dx = 0 - dy = -1 - return { nx: dx, ny: dy, penetration: radius + top } - } - dx = 0 - dy = 1 - return { nx: dx, ny: dy, penetration: radius + bottom } + if (min === left) return { nx: -1, ny: 0, penetration: radius + left } + if (min === right) return { nx: 1, ny: 0, penetration: radius + right } + if (min === top) return { nx: 0, ny: -1, penetration: radius + top } + return { nx: 0, ny: 1, penetration: radius + bottom } } const distance = Math.sqrt(distanceSquared) @@ -468,7 +464,7 @@ function bodyColliderCollision( shape.height ) } - const collision = aabbAabbCollision( + return aabbAabbCollision( shape.x, shape.y, shape.width, @@ -478,7 +474,6 @@ function bodyColliderCollision( body.shape.width, body.shape.height ) - return collision } const radius = @@ -559,7 +554,8 @@ function cloneCollider(collider: MutableCollider): MutableCollider { } } -function valueAtPath(source: unknown, path: string): unknown { +/** Read a dot-separated property path off an arbitrary value. Shared across physics controllers. */ +export function valueAtPath(source: unknown, path: string): unknown { if (!path) return undefined let current = source for (const part of path.split(".")) { @@ -573,11 +569,16 @@ function valueIn(value: unknown, options: unknown[] | undefined): boolean { return Boolean(options?.some((candidate) => Object.is(value, candidate))) } -function colliderAppliesToBody( - collider: MutableCollider, - body: MutableBody +/** + * Test whether a body matches a `PhysicsColliderBodyFilter` — a predicate + * function or a declarative equals/notEquals/oneOf/notOneOf spec. Shared by + * collider/sensor permeability here and by the capacity-queue and + * service-operations controllers' own `bodyFilter` options. + */ +export function matchesPhysicsBodyFilter( + body: PhysicsBodyState, + filter: PhysicsColliderBodyFilter | undefined ): boolean { - const filter = collider.bodyFilter if (!filter) return true if (typeof filter === "function") return filter(body) @@ -589,6 +590,13 @@ function colliderAppliesToBody( return true } +function colliderAppliesToBody( + collider: MutableCollider, + body: MutableBody +): boolean { + return matchesPhysicsBodyFilter(body, collider.bodyFilter) +} + export class PhysicsKernelWorld { readonly capabilities = { determinism: "strict" as const, @@ -1015,10 +1023,7 @@ export class PhysicsKernelWorld { if (velocityAlongNormal > 0) return const impactSpeed = Math.abs(velocityAlongNormal) - const restitution = Math.min( - a.restitution ?? this.options.restitution, - b.restitution ?? this.options.restitution - ) + const restitution = pairRestitution(a.restitution, b.restitution, this.options.restitution) const impulse = (-(1 + restitution) * velocityAlongNormal) / invTotal const ix = impulse * collision.nx const iy = impulse * collision.ny @@ -1048,16 +1053,7 @@ export class PhysicsKernelWorld { const tangentVelocity = rvx * tx + rvy * ty const invTotal = invA + invB if (Math.abs(tangentVelocity) <= EPSILON || invTotal <= EPSILON) return - const friction = Math.max( - 0, - Math.min( - 1, - Math.max( - a.friction ?? this.options.friction, - b.friction ?? this.options.friction - ) - ) - ) + const friction = pairFriction(a.friction, b.friction, this.options.friction) const frictionImpulse = clamp( -tangentVelocity / invTotal, -normalImpulse * friction, @@ -1110,26 +1106,14 @@ export class PhysicsKernelWorld { const velocityAlongNormal = body.vx * collision.nx + body.vy * collision.ny if (velocityAlongNormal < 0) { - const restitution = Math.min( - body.restitution ?? this.options.restitution, - collider.restitution ?? this.options.restitution - ) + const restitution = pairRestitution(body.restitution, collider.restitution, this.options.restitution) body.vx -= (1 + restitution) * velocityAlongNormal * collision.nx body.vy -= (1 + restitution) * velocityAlongNormal * collision.ny const tx = -collision.ny const ty = collision.nx const tangentVelocity = body.vx * tx + body.vy * ty - const friction = Math.max( - 0, - Math.min( - 1, - Math.max( - body.friction ?? this.options.friction, - collider.friction ?? this.options.friction - ) - ) - ) + const friction = pairFriction(body.friction, collider.friction, this.options.friction) body.vx -= tangentVelocity * tx * friction body.vy -= tangentVelocity * ty * friction } diff --git a/src/components/stream/physics/PhysicsSVGOverlay.tsx b/src/components/stream/physics/PhysicsSVGOverlay.tsx index 8d5e04fc2..62b4fea96 100644 --- a/src/components/stream/physics/PhysicsSVGOverlay.tsx +++ b/src/components/stream/physics/PhysicsSVGOverlay.tsx @@ -233,6 +233,7 @@ export function PhysicsSVGOverlay(props: PhysicsSVGOverlayProps) { textAnchor="middle" fontWeight={600} fill="var(--semiotic-text, #333)" + fontSize={14} className="semiotic-chart-title" style={{ fontSize: "var(--semiotic-title-font-size, 14px)" }} > diff --git a/src/components/stream/physics/ServiceOperationsControllers.ts b/src/components/stream/physics/ServiceOperationsControllers.ts index 479707ea2..d1f163b88 100644 --- a/src/components/stream/physics/ServiceOperationsControllers.ts +++ b/src/components/stream/physics/ServiceOperationsControllers.ts @@ -1,4 +1,5 @@ -import type { PhysicsBodyState, PhysicsColliderBodyFilter } from "./PhysicsKernel" +import type { PhysicsBodyState } from "./PhysicsKernel" +import { valueAtPath, matchesPhysicsBodyFilter } from "./PhysicsKernel" import type { PhysicsController } from "./PhysicsControllers" @@ -15,29 +16,6 @@ import type { ServiceResourcePoolSnapshot } from "./ServiceOperationsTypes" -function valueAtPath(source: unknown, path: string): unknown { - if (!path) return undefined - let current = source - for (const part of path.split(".")) { - if (current == null || typeof current !== "object") return undefined - current = (current as Record)[part] - } - return current -} - -function matchesFilter( - body: PhysicsBodyState, - filter: PhysicsColliderBodyFilter | undefined -): boolean { - if (!filter) return true - if (typeof filter === "function") return filter(body) - const value = valueAtPath(body, filter.property) - if ("equals" in filter && !Object.is(value, filter.equals)) return false - if ("notEquals" in filter && Object.is(value, filter.notEquals)) return false - if (filter.oneOf && !filter.oneOf.some((candidate) => Object.is(value, candidate))) return false - return !(filter.notOneOf && filter.notOneOf.some((candidate) => Object.is(value, candidate))) -} - export interface ServiceResourcePoolController extends PhysicsController { assign: (caseBodyId: string) => ServiceResourceAssignment | null release: (caseBodyId: string) => boolean @@ -192,7 +170,7 @@ export function createServiceLevelController( tick: (ctx) => { currentTime = Math.max(currentTime, ctx.elapsed) for (const body of ctx.controls.readBodies()) { - if (!matchesFilter(body, options.bodyFilter)) continue + if (!matchesPhysicsBodyFilter(body, options.bodyFilter)) continue if (!cases.has(body.id)) { const deadline = deadlineFor(body, options) update({ bodyId: body.id, openedAt: currentTime, deadlineAt: currentTime + deadline, state: "waiting" }) @@ -256,7 +234,7 @@ export function createDependencyGateController( const present = new Map(bodies.map((body) => [body.id, body])) for (const body of bodies) { const inside = ctx.getRegionState(body.id)?.activeRegionIds.includes(options.regionId) - if (!inside || !matchesFilter(body, options.bodyFilter)) continue + if (!inside || !matchesPhysicsBodyFilter(body, options.bodyFilter)) continue if (!open && !held.has(body.id)) { held.add(body.id) metricRevision += 1 diff --git a/src/components/stream/physics/StreamPhysicsFrame.tsx b/src/components/stream/physics/StreamPhysicsFrame.tsx index 523a1a874..5b9d8af33 100644 --- a/src/components/stream/physics/StreamPhysicsFrame.tsx +++ b/src/components/stream/physics/StreamPhysicsFrame.tsx @@ -229,7 +229,6 @@ export const StreamPhysicsFrame = memo(forwardRef< const regionEffectsRef = useRef(regionEffects) regionEffectsRef.current = regionEffects const bodyForcesRef = useRef(bodyForces) - bodyForcesRef.current = bodyForces const onTickRef = useRef(onTick) onTickRef.current = onTick const composedControllers = React.useMemo( @@ -1404,14 +1403,12 @@ export const StreamPhysicsFrame = memo(forwardRef< ? tooltipContent(hoverData) : : null - const adjustedWidth = Math.max(1, size[0] - margin.left - margin.right) - const adjustedHeight = Math.max(1, size[1] - margin.top - margin.bottom) const tooltipElement = tooltipRendered && hoverData ? ( diff --git a/src/components/stream/physics/physicsBodyCanvas.ts b/src/components/stream/physics/physicsBodyCanvas.ts index a1096603f..57566aa6e 100644 --- a/src/components/stream/physics/physicsBodyCanvas.ts +++ b/src/components/stream/physics/physicsBodyCanvas.ts @@ -67,7 +67,11 @@ export function drawBody( style: Style ): void { const fill = coerceCanvasFill(ctx, style.fill) ?? "#4e79a7" - const stroke = style.stroke + const resolvedStroke = coerceCanvasFill(ctx, style.stroke) + // `"none"` is truthy: without this guard canvas rejects `strokeStyle = + // "none"`, silently keeps the default black, and still strokes — drawing a + // black ring where SVG (stroke="none") paints nothing. + const stroke = resolvedStroke !== "none" ? resolvedStroke : undefined const strokeWidth = style.strokeWidth ?? 0 const opacity = style.opacity ?? 1 const fillOpacity = style.fillOpacity ?? 1 diff --git a/src/components/stream/renderers/areaCanvasRenderer.ts b/src/components/stream/renderers/areaCanvasRenderer.ts index 7f31dc1eb..0fce6c1cc 100644 --- a/src/components/stream/renderers/areaCanvasRenderer.ts +++ b/src/components/stream/renderers/areaCanvasRenderer.ts @@ -4,7 +4,6 @@ import type { StreamRendererFn } from "./types" import { renderPathPulse } from "./renderPulse" import { area as d3Area, line as d3Line } from "d3-shape" import { - buildColorStopGradient, buildLinearFillGradient, resolveCanvasFill, resolveCurveFactory, @@ -177,7 +176,7 @@ export const areaCanvasRenderer: StreamRendererFn = (ctx, nodes, scales, layout) ctx.globalAlpha = nodeOpacity const baseStroke = resolveCSSColor(ctx, node.style.stroke) || node.style.stroke const strokeGrad = !hasThresholds && !hasStrokeColorBands && node.strokeGradient && node.topPath.length >= 2 - ? buildColorStopGradient( + ? buildLinearFillGradient( ctx, node.strokeGradient, baseStroke, diff --git a/src/components/stream/renderers/barCanvasRenderer.ts b/src/components/stream/renderers/barCanvasRenderer.ts index d997dddad..1d4451fa3 100644 --- a/src/components/stream/renderers/barCanvasRenderer.ts +++ b/src/components/stream/renderers/barCanvasRenderer.ts @@ -44,6 +44,39 @@ function barGradientAxis(node: RectSceneNode): { x0: number; y0: number; x1: num } } +/** + * Resolve a bar's fill — solid color/pattern, or a tip→base gradient when + * `fillGradient` is set. Shared by all three fill branches below (plain, + * roundedTop, and per-corner-radii) since none of the resolution depends on + * which path-tracing branch is drawing the bar. + */ +function resolveBarFill( + ctx: CanvasRenderingContext2D, + node: RectSceneNode +): string | CanvasPattern | CanvasGradient { + const solid = resolveCanvasFill(ctx, node.style.fill, resolveCSSColor(ctx, "var(--semiotic-primary, #007bff)")!) + // Skip gradient construction entirely when the resolved fill is a + // CanvasPattern — feeding the fallback color into the opacity branch + // would silently replace the pattern with a solid-color gradient. + const axis = barGradientAxis(node) + const grad = node.fillGradient && typeof solid === "string" + ? buildLinearFillGradient(ctx, node.fillGradient, solid, axis.x0, axis.y0, axis.x1, axis.y1) + : null + return grad || solid +} + +/** + * Apply a bar's resolved stroke style/width to `ctx`. Returns whether there + * is a stroke to draw, so callers pick `stroke()` (traced path) or + * `strokeRect()` (no active path) without repeating the guard. + */ +function prepareBarStroke(ctx: CanvasRenderingContext2D, node: RectSceneNode): boolean { + if (!node.style.stroke || node.style.stroke === "none") return false + ctx.strokeStyle = resolveCSSColor(ctx, node.style.stroke) || node.style.stroke + ctx.lineWidth = node.style.strokeWidth || 1 + return true +} + /** * Canvas bar renderer. * Renders RectSceneNode as filled rectangles. Supports icon/isotype mode @@ -64,30 +97,13 @@ export const barCanvasRenderer: StreamRendererFn = (ctx, nodes, _scales, _layout // Explicit per-corner radii (swimlane's leading/trailing rounding). // Same fill resolution as the roundedTop branch so gradients still // flow tip→base along the bar axis. - const solid = resolveCanvasFill(ctx, node.style.fill, resolveCSSColor(ctx, "var(--semiotic-primary, #007bff)")!) - const axis = barGradientAxis(node) - const grad = node.fillGradient && typeof solid === "string" - ? buildLinearFillGradient(ctx, node.fillGradient, solid, axis.x0, axis.y0, axis.x1, axis.y1) - : null - ctx.fillStyle = grad || solid + ctx.fillStyle = resolveBarFill(ctx, node) tracePerCornerPath(ctx, node) ctx.fill() - if (node.style.stroke && node.style.stroke !== "none") { - ctx.strokeStyle = resolveCSSColor(ctx, node.style.stroke) || node.style.stroke - ctx.lineWidth = node.style.strokeWidth || 1 - ctx.stroke() - } + if (prepareBarStroke(ctx, node)) ctx.stroke() } else if (node.roundedTop && node.roundedTop > 0) { // Rounded corners on the end away from the baseline - const solid = resolveCanvasFill(ctx, node.style.fill, resolveCSSColor(ctx, "var(--semiotic-primary, #007bff)")!) - // Skip gradient construction entirely when the resolved fill is a - // CanvasPattern — feeding the fallback color into the opacity branch - // would silently replace the pattern with a solid-color gradient. - const axis = barGradientAxis(node) - const grad = node.fillGradient && typeof solid === "string" - ? buildLinearFillGradient(ctx, node.fillGradient, solid, axis.x0, axis.y0, axis.x1, axis.y1) - : null - ctx.fillStyle = grad || solid + ctx.fillStyle = resolveBarFill(ctx, node) const r = Math.min(node.roundedTop, node.w / 2, node.h / 2) ctx.beginPath() const { x, y, w, h } = node @@ -128,29 +144,13 @@ export const barCanvasRenderer: StreamRendererFn = (ctx, nodes, _scales, _layout ctx.closePath() ctx.fill() - if (node.style.stroke && node.style.stroke !== "none") { - ctx.strokeStyle = resolveCSSColor(ctx, node.style.stroke) || node.style.stroke - ctx.lineWidth = node.style.strokeWidth || 1 - ctx.stroke() - } + if (prepareBarStroke(ctx, node)) ctx.stroke() } else { // Standard solid fill — or gradient when fillGradient is set. - const solid = resolveCanvasFill(ctx, node.style.fill, resolveCSSColor(ctx, "var(--semiotic-primary, #007bff)")!) - // Skip gradient construction entirely when the resolved fill is a - // CanvasPattern — feeding the fallback color into the opacity branch - // would silently replace the pattern with a solid-color gradient. - const axis = barGradientAxis(node) - const grad = node.fillGradient && typeof solid === "string" - ? buildLinearFillGradient(ctx, node.fillGradient, solid, axis.x0, axis.y0, axis.x1, axis.y1) - : null - ctx.fillStyle = grad || solid + ctx.fillStyle = resolveBarFill(ctx, node) ctx.fillRect(node.x, node.y, node.w, node.h) - if (node.style.stroke && node.style.stroke !== "none") { - ctx.strokeStyle = resolveCSSColor(ctx, node.style.stroke) || node.style.stroke - ctx.lineWidth = node.style.strokeWidth || 1 - ctx.strokeRect(node.x, node.y, node.w, node.h) - } + if (prepareBarStroke(ctx, node)) ctx.strokeRect(node.x, node.y, node.w, node.h) } // Pulse overlay diff --git a/src/components/stream/renderers/canvasRenderHelpers.test.ts b/src/components/stream/renderers/canvasRenderHelpers.test.ts index bad3df26b..925b50af1 100644 --- a/src/components/stream/renderers/canvasRenderHelpers.test.ts +++ b/src/components/stream/renderers/canvasRenderHelpers.test.ts @@ -1,11 +1,13 @@ -import { describe, it, expect, beforeEach } from "vitest" +import { describe, it, expect, beforeEach, vi } from "vitest" import { resolveCurveFactory, resolveCanvasFill, buildLinearFillGradient, - buildColorStopGradient, + paintNetworkFill, + paintNetworkStroke, } from "./canvasRenderHelpers" import { createMockCanvasContext } from "../../../test-utils/canvasMock" +import type { Style } from "../types" describe("canvasRenderHelpers", () => { let ctx: CanvasRenderingContext2D @@ -121,39 +123,67 @@ describe("canvasRenderHelpers", () => { }) }) - describe("buildColorStopGradient", () => { - it("returns null when fewer than 2 stops are present", () => { - const grad = buildColorStopGradient( - ctx, - { stops: [{ offset: 0, color: "red" }] }, - "#000", - 0, 0, 100, 0, - ) - expect(grad).toBeNull() + describe("paintNetworkFill", () => { + it("does not invoke paint when style.fill is unset", () => { + const paint = vi.fn() + paintNetworkFill(ctx, {} as Style, "#007bff", paint) + expect(paint).not.toHaveBeenCalled() }) - it("builds a gradient when 2+ stops are present", () => { - const grad = buildColorStopGradient( - ctx, - { stops: [ - { offset: 0, color: "red" }, - { offset: 1, color: "blue" }, - ]}, - "#000", 0, 0, 100, 0, - ) - expect(grad).toBeTruthy() + it("resolves fillStyle from the fallback and invokes paint", () => { + const paint = vi.fn() + paintNetworkFill(ctx, { fill: "#ff0000" } as Style, "#007bff", paint) + expect(paint).toHaveBeenCalledOnce() + expect((ctx as unknown as { fillStyle: string }).fillStyle).toBe("#ff0000") }) - it("filters non-finite offsets before the 2-stop minimum (avoids addColorStop IndexSizeError)", () => { - const grad = buildColorStopGradient( - ctx, - { stops: [ - { offset: 0, color: "red" }, - { offset: NaN, color: "green" }, - ]}, - "#000", 0, 0, 100, 0, - ) - expect(grad).toBeNull() + it("combines opacity × fillOpacity into globalAlpha when fillOpacity is set", () => { + const paint = vi.fn() + paintNetworkFill(ctx, { fill: "#ff0000", opacity: 0.5, fillOpacity: 0.4 } as Style, "#007bff", paint) + expect((ctx as unknown as { globalAlpha: number }).globalAlpha).toBeCloseTo(0.2) + }) + + it("leaves globalAlpha untouched when fillOpacity is unset", () => { + const mutableCtx = ctx as unknown as { globalAlpha: number } + mutableCtx.globalAlpha = 0.75 + const paint = vi.fn() + paintNetworkFill(ctx, { fill: "#ff0000", opacity: 0.5 } as Style, "#007bff", paint) + expect(mutableCtx.globalAlpha).toBe(0.75) + }) + }) + + describe("paintNetworkStroke", () => { + it("does not invoke paint when style.stroke is unset", () => { + const paint = vi.fn() + paintNetworkStroke(ctx, {} as Style, paint) + expect(paint).not.toHaveBeenCalled() + }) + + it("does not invoke paint when style.stroke is \"none\" (the stroke=\"none\" canvas trap)", () => { + // `strokeStyle = "none"` is rejected by a real canvas context, which + // silently keeps the default black and still strokes — regression + // coverage for that trap: "none" must short-circuit before paint(). + const paint = vi.fn() + paintNetworkStroke(ctx, { stroke: "none" } as Style, paint) + expect(paint).not.toHaveBeenCalled() + }) + + it("resolves strokeStyle/lineWidth/globalAlpha and invokes paint for a real color", () => { + const paint = vi.fn() + paintNetworkStroke(ctx, { stroke: "#00ff00", strokeWidth: 3, opacity: 0.6 } as Style, paint) + expect(paint).toHaveBeenCalledOnce() + const mutableCtx = ctx as unknown as { strokeStyle: string; lineWidth: number; globalAlpha: number } + expect(mutableCtx.strokeStyle).toBe("#00ff00") + expect(mutableCtx.lineWidth).toBe(3) + expect(mutableCtx.globalAlpha).toBe(0.6) + }) + + it("defaults lineWidth to 1 and globalAlpha to 1 when unset", () => { + const paint = vi.fn() + paintNetworkStroke(ctx, { stroke: "#00ff00" } as Style, paint) + const mutableCtx = ctx as unknown as { lineWidth: number; globalAlpha: number } + expect(mutableCtx.lineWidth).toBe(1) + expect(mutableCtx.globalAlpha).toBe(1) }) }) }) diff --git a/src/components/stream/renderers/canvasRenderHelpers.ts b/src/components/stream/renderers/canvasRenderHelpers.ts index 6b7ce2623..1b440caed 100644 --- a/src/components/stream/renderers/canvasRenderHelpers.ts +++ b/src/components/stream/renderers/canvasRenderHelpers.ts @@ -1,5 +1,5 @@ /** Shared canvas primitives for fills, strokes, curves, and gradients. */ -import type { CurveType } from "../types" +import type { CurveType, Style } from "../types" import type { GradientConfig, GradientStop } from "../../charts/shared/gradient" import { resolveCSSColor } from "./resolveCSSColor" import { parseCanvasColor } from "./colorUtils" @@ -91,7 +91,9 @@ export function coerceCanvasFill( /** * Build a linear gradient from a normalized gradient config. Stops without a - * color inherit the resolved mark color. + * color inherit the resolved mark color. Used for both fill gradients (bar, + * area fill) and stroke gradients (line, area stroke) — a canvas gradient + * object is equally valid as `fillStyle` or `strokeStyle`. * * The renderer is responsible for choosing the gradient axis (a bar * runs tip→base along its value axis; an area runs minTop→maxBottom @@ -134,27 +136,38 @@ function canvasGradientStopColor( return `rgba(${r},${g},${b},${opacity})` } -/** Build a linear stroke gradient along the caller-provided axis. */ -export function buildColorStopGradient( +/** + * Resolve and apply a network node's fill, then invoke `paint` (the + * renderer's own `fill()`/`fillRect()` call — the path or rect traced + * differs per shape, so painting stays with the caller). + */ +export function paintNetworkFill( ctx: CanvasRenderingContext2D, - strokeGradient: GradientConfig, - baseStroke: string, - x0: number, - y0: number, - x1: number, - y1: number, -): CanvasGradient | null { - const validStops = strokeGradient.stops.filter((stop) => - Number.isFinite(stop.offset) - && (stop.opacity == null || Number.isFinite(stop.opacity)), - ) - if (validStops.length < 2) return null - const grad = ctx.createLinearGradient(x0, y0, x1, y1) - for (const stop of validStops) { - grad.addColorStop( - Math.max(0, Math.min(1, stop.offset)), - canvasGradientStopColor(ctx, stop, baseStroke), - ) + style: Style, + fallback: string, + paint: () => void, +): void { + if (!style.fill) return + ctx.fillStyle = resolveCanvasFill(ctx, style.fill, fallback) + if (style.fillOpacity !== undefined) { + ctx.globalAlpha = (style.opacity ?? 1) * style.fillOpacity } - return grad + paint() +} + +/** + * Resolve and apply a network node's stroke, then invoke `paint`. `"none"` + * is truthy as a string, so this guards it explicitly — canvas otherwise + * rejects `strokeStyle = "none"`, keeps the default black, and still strokes. + */ +export function paintNetworkStroke( + ctx: CanvasRenderingContext2D, + style: Style, + paint: () => void, +): void { + if (!style.stroke || style.stroke === "none") return + ctx.strokeStyle = resolveCSSColor(ctx, style.stroke) || style.stroke + ctx.lineWidth = style.strokeWidth ?? 1 + ctx.globalAlpha = style.opacity ?? 1 + paint() } diff --git a/src/components/stream/renderers/lineCanvasRenderer.ts b/src/components/stream/renderers/lineCanvasRenderer.ts index 66eb54a93..d575ecbc4 100644 --- a/src/components/stream/renderers/lineCanvasRenderer.ts +++ b/src/components/stream/renderers/lineCanvasRenderer.ts @@ -2,7 +2,7 @@ import type { LineSceneNode } from "../types" import { resolveCSSColor } from "./resolveCSSColor" import type { StreamRendererFn } from "./types" import { line as d3Line } from "d3-shape" -import { buildColorStopGradient, resolveCanvasFill, resolveCurveFactory } from "./canvasRenderHelpers" +import { buildLinearFillGradient, resolveCanvasFill, resolveCurveFactory } from "./canvasRenderHelpers" import { buildThresholdLineSegments } from "./thresholdLineSegments" /** @@ -142,7 +142,7 @@ export const lineCanvasRenderer: StreamRendererFn = (ctx, nodes, scales, layout) ctx.beginPath() const strokeGrad = node.strokeGradient && node.path.length >= 2 - ? buildColorStopGradient( + ? buildLinearFillGradient( ctx, node.strokeGradient, baseColor, diff --git a/src/components/stream/renderers/networkArcRenderer.ts b/src/components/stream/renderers/networkArcRenderer.ts index 7e9907728..2ef8077a2 100644 --- a/src/components/stream/renderers/networkArcRenderer.ts +++ b/src/components/stream/renderers/networkArcRenderer.ts @@ -1,6 +1,5 @@ import type { NetworkSceneNode, NetworkArcNode } from "../networkTypes" -import { resolveCSSColor } from "./resolveCSSColor" -import { resolveCanvasFill } from "./canvasRenderHelpers" +import { paintNetworkFill, paintNetworkStroke } from "./canvasRenderHelpers" /** * Canvas painter for NetworkArcNode (chord diagram arc segments). @@ -25,22 +24,8 @@ export function networkArcRenderer( ctx.arc(a.cx, a.cy, a.innerR, a.endAngle, a.startAngle, true) ctx.closePath() - // Fill - if (a.style.fill) { - ctx.fillStyle = resolveCanvasFill(ctx, a.style.fill, "#007bff") - if (a.style.fillOpacity !== undefined) { - ctx.globalAlpha = (a.style.opacity ?? 1) * a.style.fillOpacity - } - ctx.fill() - } - - // Stroke - if (a.style.stroke && a.style.stroke !== "none") { - ctx.strokeStyle = resolveCSSColor(ctx, a.style.stroke) || a.style.stroke - ctx.lineWidth = a.style.strokeWidth ?? 1 - ctx.globalAlpha = a.style.opacity ?? 1 - ctx.stroke() - } + paintNetworkFill(ctx, a.style, "#007bff", () => ctx.fill()) + paintNetworkStroke(ctx, a.style, () => ctx.stroke()) ctx.restore() } diff --git a/src/components/stream/renderers/networkCircleRenderer.ts b/src/components/stream/renderers/networkCircleRenderer.ts index 4a514bf68..2c94d54bf 100644 --- a/src/components/stream/renderers/networkCircleRenderer.ts +++ b/src/components/stream/renderers/networkCircleRenderer.ts @@ -1,7 +1,6 @@ import type { NetworkSceneNode, NetworkCircleNode } from "../networkTypes" import { renderCirclePulse } from "./renderPulse" -import { resolveCSSColor } from "./resolveCSSColor" -import { resolveCanvasFill } from "./canvasRenderHelpers" +import { paintNetworkFill, paintNetworkStroke } from "./canvasRenderHelpers" /** * Canvas painter for NetworkCircleNode (force nodes, tree nodes, circlepack). @@ -25,22 +24,8 @@ export function networkCircleRenderer( ctx.beginPath() ctx.arc(c.cx, c.cy, c.r, 0, Math.PI * 2) - // Fill - if (c.style.fill) { - ctx.fillStyle = resolveCanvasFill(ctx, c.style.fill, "#007bff") - if (c.style.fillOpacity !== undefined) { - ctx.globalAlpha = (c.style.opacity ?? 1) * c.style.fillOpacity - } - ctx.fill() - } - - // Stroke - if (c.style.stroke && c.style.stroke !== "none") { - ctx.strokeStyle = resolveCSSColor(ctx, c.style.stroke) || c.style.stroke - ctx.lineWidth = c.style.strokeWidth ?? 1 - ctx.globalAlpha = c.style.opacity ?? 1 - ctx.stroke() - } + paintNetworkFill(ctx, c.style, "#007bff", () => ctx.fill()) + paintNetworkStroke(ctx, c.style, () => ctx.stroke()) // Pulse glow ring renderCirclePulse(ctx, c) diff --git a/src/components/stream/renderers/networkRectRenderer.ts b/src/components/stream/renderers/networkRectRenderer.ts index c756d9357..9424e3efb 100644 --- a/src/components/stream/renderers/networkRectRenderer.ts +++ b/src/components/stream/renderers/networkRectRenderer.ts @@ -1,7 +1,6 @@ import type { NetworkSceneNode, NetworkRectNode } from "../networkTypes" import { renderRectPulse } from "./renderPulse" -import { resolveCSSColor } from "./resolveCSSColor" -import { resolveCanvasFill } from "./canvasRenderHelpers" +import { paintNetworkFill, paintNetworkStroke } from "./canvasRenderHelpers" /** * Canvas painter for NetworkRectNode (sankey nodes, treemap cells, partition blocks). @@ -22,22 +21,8 @@ export function networkRectRenderer( ctx.globalAlpha = r.style.opacity } - // Fill - if (r.style.fill) { - ctx.fillStyle = resolveCanvasFill(ctx, r.style.fill, "#007bff") - if (r.style.fillOpacity !== undefined) { - ctx.globalAlpha = (r.style.opacity ?? 1) * r.style.fillOpacity - } - ctx.fillRect(r.x, r.y, r.w, r.h) - } - - // Stroke - if (r.style.stroke && r.style.stroke !== "none") { - ctx.strokeStyle = resolveCSSColor(ctx, r.style.stroke) || r.style.stroke - ctx.lineWidth = r.style.strokeWidth ?? 1 - ctx.globalAlpha = r.style.opacity ?? 1 - ctx.strokeRect(r.x, r.y, r.w, r.h) - } + paintNetworkFill(ctx, r.style, "#007bff", () => ctx.fillRect(r.x, r.y, r.w, r.h)) + paintNetworkStroke(ctx, r.style, () => ctx.strokeRect(r.x, r.y, r.w, r.h)) // Pulse overlay renderRectPulse(ctx, r) diff --git a/src/components/stream/renderers/networkSymbolRenderer.ts b/src/components/stream/renderers/networkSymbolRenderer.ts deleted file mode 100644 index 0016ce256..000000000 --- a/src/components/stream/renderers/networkSymbolRenderer.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { NetworkSceneNode, NetworkSymbolNode } from "../networkTypes" -import { resolveCSSColor } from "./resolveCSSColor" -import { resolveCanvasFill } from "./canvasRenderHelpers" -import { symbolPathString } from "../symbolPath" - -/** - * Canvas painter for `NetworkSymbolNode` — the per-datum shape channel. - * - * Each glyph is a `Path2D` translated to the node center (and optionally - * rotated), filled/stroked from `style`. Named shapes are cached by - * `symbolType:size`, so a scene of thousands of marks parses only a handful of - * distinct paths. Mirrors the violin renderer's `Path2D` approach. - */ -const PATH_CACHE = new Map() - -function getSymbolPath2D(node: NetworkSymbolNode): Path2D | null { - try { - if (node.path) return new Path2D(node.path) - const key = `${node.symbolType ?? "circle"}:${Math.round(node.size)}` - let p = PATH_CACHE.get(key) - if (!p) { - p = new Path2D(symbolPathString(node.symbolType, node.size)) - if (PATH_CACHE.size > 256) PATH_CACHE.clear() - PATH_CACHE.set(key, p) - } - return p - } catch { - return null - } -} - -export function networkSymbolRenderer( - ctx: CanvasRenderingContext2D, - nodes: NetworkSceneNode[] -): void { - const baseAlpha = ctx.globalAlpha - for (const node of nodes) { - if (node.type !== "symbol") continue - const s = node as NetworkSymbolNode - if (s.size <= 0) continue - const path = getSymbolPath2D(s) - if (!path) continue - - ctx.save() - ctx.translate(s.cx, s.cy) - if (s.rotation) ctx.rotate(s.rotation) - - const nodeAlpha = s.style.opacity ?? 1 - - if (s.style.fill) { - ctx.globalAlpha = baseAlpha * nodeAlpha * (s.style.fillOpacity ?? 1) - ctx.fillStyle = resolveCanvasFill(ctx, s.style.fill, "#007bff") - ctx.fill(path) - } - - if (s.style.stroke && s.style.stroke !== "none") { - ctx.globalAlpha = baseAlpha * nodeAlpha - ctx.strokeStyle = resolveCSSColor(ctx, s.style.stroke) || s.style.stroke - ctx.lineWidth = s.style.strokeWidth ?? 1 - ctx.stroke(path) - } - - ctx.restore() - } -} diff --git a/src/components/stream/renderers/symbolCanvasRenderer.ts b/src/components/stream/renderers/symbolCanvasRenderer.ts index 0604e74bc..2ff93236e 100644 --- a/src/components/stream/renderers/symbolCanvasRenderer.ts +++ b/src/components/stream/renderers/symbolCanvasRenderer.ts @@ -1,29 +1,36 @@ import type { SymbolSceneNode } from "../types" +import type { NetworkSceneNode, NetworkSymbolNode } from "../networkTypes" import type { StreamRendererFn } from "./types" +import { resolveCSSColor } from "./resolveCSSColor" import { resolveCanvasFill } from "./canvasRenderHelpers" import { symbolPathString } from "../symbolPath" /** - * Canvas painter for `SymbolSceneNode` — the per-datum shape channel for the XY - * and ordinal pipelines (the sibling of `networkSymbolRenderer`). Both delegate - * glyph-path generation to the shared `symbolPathString`, so a glyph looks - * identical across canvas, SVG/SSR, and every chart family — there is no second - * path implementation. + * Canvas painter for the per-datum shape ("symbol") channel — one `paintSymbol` + * plus two thin exported wrappers, mirroring `glyphCanvasRenderer`'s shape for + * its sibling composite-pictogram channel. `symbolCanvasRenderer` covers the + * XY/ordinal pipeline (x/y-positioned nodes); `networkSymbolRenderer` covers + * network (cx/cy-positioned nodes). Both delegate glyph-path generation to + * `symbolPathString`, so a glyph looks identical across canvas, SVG/SSR, and + * every chart family — there is no second path implementation. * - * Named shapes are cached as `Path2D` by `symbolType:size`, so a scene of - * thousands of marks parses only a handful of distinct paths. Honors the - * incoming canvas alpha (chart-wide staleness dim) multiplied by each node's - * own opacity / decay. + * Named shapes are cached as `Path2D` by `symbolType:size` in one shared + * cache, so a scene of thousands of marks across both pipelines parses only + * a handful of distinct paths. */ const PATH_CACHE = new Map() -function getSymbolPath2D(node: SymbolSceneNode): Path2D | null { +function getSymbolPath2D( + symbolType: string | undefined, + size: number, + path: string | undefined +): Path2D | null { try { - if (node.path) return new Path2D(node.path) - const key = `${node.symbolType ?? "circle"}:${Math.round(node.size)}` + if (path) return new Path2D(path) + const key = `${symbolType ?? "circle"}:${Math.round(size)}` let p = PATH_CACHE.get(key) if (!p) { - p = new Path2D(symbolPathString(node.symbolType, node.size)) + p = new Path2D(symbolPathString(symbolType, size)) if (PATH_CACHE.size > 256) PATH_CACHE.clear() PATH_CACHE.set(key, p) } @@ -33,35 +40,63 @@ function getSymbolPath2D(node: SymbolSceneNode): Path2D | null { } } +/** Honors the incoming canvas alpha (chart-wide staleness dim) times each node's own opacity / decay. */ +function paintSymbol( + ctx: CanvasRenderingContext2D, + node: SymbolSceneNode | NetworkSymbolNode, + x: number, + y: number, + baseAlpha: number, + defaultFill: string +): void { + if (node.size <= 0) return + const path = getSymbolPath2D(node.symbolType, node.size, node.path) + if (!path) return + + ctx.save() + ctx.translate(x, y) + if (node.rotation) ctx.rotate(node.rotation) + + const decay = (node as SymbolSceneNode)._decayOpacity ?? 1 + const nodeAlpha = (node.style.opacity ?? 1) * decay + + if (node.style.fill) { + ctx.globalAlpha = baseAlpha * nodeAlpha * (node.style.fillOpacity ?? 1) + ctx.fillStyle = resolveCanvasFill(ctx, node.style.fill, defaultFill) + ctx.fill(path) + } + + if (node.style.stroke && node.style.stroke !== "none") { + ctx.globalAlpha = baseAlpha * nodeAlpha + ctx.strokeStyle = resolveCSSColor(ctx, node.style.stroke) || node.style.stroke + ctx.lineWidth = node.style.strokeWidth ?? 1 + ctx.stroke(path) + } + + ctx.restore() +} + +/** XY / ordinal painter (x/y-positioned symbol nodes). */ export const symbolCanvasRenderer: StreamRendererFn = (ctx, nodes) => { const baseAlpha = ctx.globalAlpha for (const node of nodes) { if (node.type !== "symbol") continue const s = node as SymbolSceneNode - if (s.size <= 0) continue - const path = getSymbolPath2D(s) - if (!path) continue - - ctx.save() - ctx.translate(s.x, s.y) - if (s.rotation) ctx.rotate(s.rotation) - - const nodeAlpha = (s.style.opacity ?? 1) * (s._decayOpacity ?? 1) - - if (s.style.fill) { - ctx.globalAlpha = baseAlpha * nodeAlpha * (s.style.fillOpacity ?? 1) - ctx.fillStyle = resolveCanvasFill(ctx, s.style.fill, "#4e79a7") - ctx.fill(path) - } - - if (s.style.stroke && s.style.stroke !== "none") { - ctx.globalAlpha = baseAlpha * nodeAlpha - ctx.strokeStyle = resolveCanvasFill(ctx, s.style.stroke, s.style.stroke) - ctx.lineWidth = s.style.strokeWidth ?? 1 - ctx.stroke(path) - } + paintSymbol(ctx, s, s.x, s.y, baseAlpha, "#4e79a7") + } + ctx.globalAlpha = baseAlpha +} - ctx.restore() +/** Network painter (cx/cy-positioned symbol nodes). */ +export function networkSymbolRenderer( + ctx: CanvasRenderingContext2D, + nodes: NetworkSceneNode[] +): void { + const baseAlpha = ctx.globalAlpha + for (const node of nodes) { + if (node.type !== "symbol") continue + const s = node as NetworkSymbolNode + paintSymbol(ctx, s, s.cx, s.cy, baseAlpha, "#007bff") } ctx.globalAlpha = baseAlpha } diff --git a/src/components/stream/renderers/trapezoidCanvasRenderer.ts b/src/components/stream/renderers/trapezoidCanvasRenderer.ts index 91fcf6d05..f54abd041 100644 --- a/src/components/stream/renderers/trapezoidCanvasRenderer.ts +++ b/src/components/stream/renderers/trapezoidCanvasRenderer.ts @@ -30,7 +30,10 @@ export const trapezoidCanvasRenderer = ( ctx.fillStyle = coerceCanvasFill(ctx, node.style?.fill) || "#999" ctx.fill() - if (node.style?.stroke) { + // `"none"` is truthy: without this guard canvas rejects `strokeStyle = + // "none"`, silently keeps the default black, and still strokes — drawing a + // black outline where SVG (stroke="none") paints nothing. + if (node.style?.stroke && node.style.stroke !== "none") { ctx.strokeStyle = resolveCSSColor(ctx, node.style.stroke) || node.style.stroke ctx.lineWidth = node.style.strokeWidth || 1 ctx.stroke() diff --git a/src/components/stream/symbolPath.ts b/src/components/stream/symbolPath.ts index ca4a759da..a08129bd9 100644 --- a/src/components/stream/symbolPath.ts +++ b/src/components/stream/symbolPath.ts @@ -20,6 +20,7 @@ import { symbolWye, type SymbolType, } from "d3-shape" +import type { Datum } from "../charts/shared/datumTypes" /** Named glyph shapes a `symbol` scene node can request. */ export type NetworkSymbolName = @@ -116,6 +117,32 @@ export function symbolExtent( return max } +/** + * Build a per-scene category→shape resolver for the `symbolBy` channel. + * Explicit `symbolMap` wins; unmapped categories auto-assign from + * `SYMBOL_SEQUENCE` in first-seen (deterministic) order. Shared by the XY + * and ordinal point scene builders so Scatterplot/SwarmPlot glyphs agree. + */ +export function makeSymbolResolver( + getSymbol: (d: Datum) => unknown, + symbolMap: Record | undefined +): (d: Datum) => SymbolName { + const assign = new Map() + let seq = 0 + return (d: Datum) => { + const cat = String(getSymbol(d)) + const explicit = symbolMap?.[cat] + if (explicit) return explicit + let s = assign.get(cat) + if (!s) { + s = SYMBOL_SEQUENCE[seq % SYMBOL_SEQUENCE.length] + seq++ + assign.set(cat, s) + } + return s + } +} + /** A concave upward dart — visually distinct from `triangle`, sized to roughly * match a d3 symbol of the same `size`. */ function chevronPath(size: number): string { diff --git a/src/components/stream/xySceneBuilders/pointScene.ts b/src/components/stream/xySceneBuilders/pointScene.ts index 2433d7e12..1c5d4699d 100644 --- a/src/components/stream/xySceneBuilders/pointScene.ts +++ b/src/components/stream/xySceneBuilders/pointScene.ts @@ -10,7 +10,7 @@ import type { Datum } from "../../charts/shared/datumTypes" */ import type { PointSceneNode, SymbolSceneNode } from "../types" import { buildPointNode, buildSymbolNode } from "../SceneGraph" -import { SYMBOL_SEQUENCE, type SymbolName } from "../symbolPath" +import { makeSymbolResolver } from "../symbolPath" import type { XYSceneContext } from "./types" export function buildPointScene(ctx: XYSceneContext, data: Datum[]): (PointSceneNode | SymbolSceneNode)[] { @@ -46,23 +46,9 @@ export function buildPointScene(ctx: XYSceneContext, data: Datum[]): (PointScene const themedDefaultFill = ctx.config.themeSemantic?.primary || "#4e79a7" // Symbol (shape) channel: when a symbol accessor is set each mark renders as a - // glyph instead of a circle. Explicit `symbolMap` wins; unmapped categories - // auto-assign from SYMBOL_SEQUENCE in first-seen (deterministic) order. + // glyph instead of a circle. const getSymbol = ctx.getSymbol - const symbolMapCfg = ctx.config.symbolMap - const symbolAssign = new Map() - let symSeq = 0 - const shapeFor = (cat: string): SymbolName => { - const explicit = symbolMapCfg?.[cat] - if (explicit) return explicit - let s = symbolAssign.get(cat) - if (!s) { - s = SYMBOL_SEQUENCE[symSeq % SYMBOL_SEQUENCE.length] - symSeq++ - symbolAssign.set(cat, s) - } - return s - } + const shapeFor = getSymbol ? makeSymbolResolver(getSymbol, ctx.config.symbolMap) : null for (const d of data) { let style = ctx.config.pointStyle ? ctx.config.pointStyle(d) : { fill: themedDefaultFill, opacity: 0.8 } @@ -83,10 +69,10 @@ export function buildPointScene(ctx: XYSceneContext, data: Datum[]): (PointScene } const pointId = ctx.getPointId ? String(ctx.getPointId(d)) : undefined - if (getSymbol) { + if (shapeFor) { // Encode the computed radius as d3-symbol area (πr²) so glyph size still // tracks `sizeBy`/`pointRadius`. - const shape = shapeFor(String(getSymbol(d))) + const shape = shapeFor(d) const node = buildSymbolNode(d, ctx.scales, ctx.getX, ctx.getY, Math.PI * r * r, shape, style, pointId) if (node) nodes.push(node) } else {