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 {