@epilot/spark-ui 1.0.0-alpha.3 → 1.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/components/attachment.d.cts +1 -1
  2. package/dist/components/attachment.d.ts +1 -1
  3. package/dist/components/bar-chart.cjs +48 -47
  4. package/dist/components/bar-chart.cjs.map +1 -1
  5. package/dist/components/bar-chart.d.cts +7 -2
  6. package/dist/components/bar-chart.d.ts +7 -2
  7. package/dist/components/bar-chart.js +48 -47
  8. package/dist/components/bar-chart.js.map +1 -1
  9. package/dist/components/bubble.d.cts +1 -1
  10. package/dist/components/bubble.d.ts +1 -1
  11. package/dist/components/button.cjs +1 -1
  12. package/dist/components/button.cjs.map +1 -1
  13. package/dist/components/button.d.cts +8 -0
  14. package/dist/components/button.d.ts +8 -0
  15. package/dist/components/button.js +1 -1
  16. package/dist/components/button.js.map +1 -1
  17. package/dist/components/callout.d.cts +1 -1
  18. package/dist/components/callout.d.ts +1 -1
  19. package/dist/components/energy-bar-chart.cjs +2 -1
  20. package/dist/components/energy-bar-chart.cjs.map +1 -1
  21. package/dist/components/energy-bar-chart.js +2 -1
  22. package/dist/components/energy-bar-chart.js.map +1 -1
  23. package/dist/components/energy-history-chart.cjs +14 -6
  24. package/dist/components/energy-history-chart.cjs.map +1 -1
  25. package/dist/components/energy-history-chart.d.cts +11 -3
  26. package/dist/components/energy-history-chart.d.ts +11 -3
  27. package/dist/components/energy-history-chart.js +14 -6
  28. package/dist/components/energy-history-chart.js.map +1 -1
  29. package/dist/components/field.cjs +1 -1
  30. package/dist/components/field.js +1 -1
  31. package/dist/components/input-group.cjs +1 -1
  32. package/dist/components/input-group.js +1 -1
  33. package/dist/components/line-chart.cjs +55 -15
  34. package/dist/components/line-chart.cjs.map +1 -1
  35. package/dist/components/line-chart.d.cts +7 -0
  36. package/dist/components/line-chart.d.ts +7 -0
  37. package/dist/components/line-chart.js +55 -15
  38. package/dist/components/line-chart.js.map +1 -1
  39. package/dist/components/power-history-chart.cjs +14 -37
  40. package/dist/components/power-history-chart.cjs.map +1 -1
  41. package/dist/components/power-history-chart.d.cts +10 -1
  42. package/dist/components/power-history-chart.d.ts +10 -1
  43. package/dist/components/power-history-chart.js +15 -38
  44. package/dist/components/power-history-chart.js.map +1 -1
  45. package/dist/components/sankey-chart.cjs +8 -41
  46. package/dist/components/sankey-chart.cjs.map +1 -1
  47. package/dist/components/sankey-chart.d.cts +8 -0
  48. package/dist/components/sankey-chart.d.ts +8 -0
  49. package/dist/components/sankey-chart.js +8 -41
  50. package/dist/components/sankey-chart.js.map +1 -1
  51. package/dist/components/segmented-control.cjs +44 -0
  52. package/dist/components/segmented-control.cjs.map +1 -0
  53. package/dist/components/segmented-control.d.cts +33 -0
  54. package/dist/components/segmented-control.d.ts +33 -0
  55. package/dist/components/segmented-control.js +40 -0
  56. package/dist/components/segmented-control.js.map +1 -0
  57. package/dist/components/separator.cjs +1 -1
  58. package/dist/components/separator.cjs.map +1 -1
  59. package/dist/components/separator.js +1 -1
  60. package/dist/components/separator.js.map +1 -1
  61. package/dist/components/solar-forecast-chart.cjs +4 -3
  62. package/dist/components/solar-forecast-chart.cjs.map +1 -1
  63. package/dist/components/solar-forecast-chart.d.cts +4 -1
  64. package/dist/components/solar-forecast-chart.d.ts +4 -1
  65. package/dist/components/solar-forecast-chart.js +4 -3
  66. package/dist/components/solar-forecast-chart.js.map +1 -1
  67. package/dist/components/theme.cjs +1 -1
  68. package/dist/components/theme.js +1 -1
  69. package/dist/components/tooltip.cjs +1 -1
  70. package/dist/components/tooltip.cjs.map +1 -1
  71. package/dist/components/tooltip.js +1 -1
  72. package/dist/components/tooltip.js.map +1 -1
  73. package/dist/styles/index.css +37 -0
  74. package/dist/styles/spark-default.css +45 -40
  75. package/dist/styles/theme.css +78 -0
  76. package/dist/{theme-B3WtiByA.js → theme-C19BbKfO.js} +2 -2
  77. package/dist/theme-C19BbKfO.js.map +1 -0
  78. package/dist/{theme-C9_OSe0S.cjs → theme-CfFWrMEF.cjs} +2 -2
  79. package/dist/theme-CfFWrMEF.cjs.map +1 -0
  80. package/dist/utils/build-sankey-nodes.cjs +16 -3
  81. package/dist/utils/build-sankey-nodes.cjs.map +1 -1
  82. package/dist/utils/build-sankey-nodes.js +16 -3
  83. package/dist/utils/build-sankey-nodes.js.map +1 -1
  84. package/dist/utils/chart-scale.cjs +43 -0
  85. package/dist/utils/chart-scale.cjs.map +1 -0
  86. package/dist/utils/chart-scale.d.cts +22 -0
  87. package/dist/utils/chart-scale.d.ts +22 -0
  88. package/dist/utils/chart-scale.js +41 -0
  89. package/dist/utils/chart-scale.js.map +1 -0
  90. package/package.json +1 -1
  91. package/dist/theme-B3WtiByA.js.map +0 -1
  92. package/dist/theme-C9_OSe0S.cjs.map +0 -1
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import { cn } from "../utils/cn.js";
3
3
  import "../utils/chars.js";
4
- import { integerFormat, oneDecimalFormat, twoDecimalFormat } from "../utils/format.js";
4
+ import { twoDecimalFormat } from "../utils/format.js";
5
+ import { buildYAxisScale, formatYAxisTick } from "../utils/chart-scale.js";
5
6
  import { ChartTooltipCard, clampTooltipPosition, useMeasuredSize } from "./chart.js";
6
7
  import { SeriesLegend } from "./series-legend.js";
7
8
  import { SectionTitle } from "./typography.js";
@@ -38,32 +39,6 @@ const Y_AXIS_GUTTER = 34;
38
39
  const X_TICK_COUNT = 6;
39
40
  const DEFAULT_HEIGHT = 260;
40
41
  const tBisector = bisector((d) => d.t);
41
- function niceStep(rough) {
42
- const normalized = rough === 0 ? 1 : rough;
43
- const magnitude = Math.pow(10, Math.floor(Math.log10(normalized)));
44
- return ([
45
- 1,
46
- 2,
47
- 2.5,
48
- 5,
49
- 10
50
- ].find((value) => value * magnitude >= normalized) ?? 10) * magnitude;
51
- }
52
- function buildAreaYTicks(min, max) {
53
- const step = niceStep((max - min || 1) / 4);
54
- const lo = Math.floor(min / step) * step;
55
- const hi = Math.ceil(max / step) * step;
56
- const ticks = [];
57
- for (let value = lo; value <= hi + step * .001; value += step) ticks.push(Number(value.toFixed(6)));
58
- return {
59
- ticks,
60
- lo,
61
- hi
62
- };
63
- }
64
- function formatYTick(value) {
65
- return Math.abs(value) >= 10 ? integerFormat.format(value) : oneDecimalFormat.format(value);
66
- }
67
42
  /**
68
43
  * Diverging "power sources" area chart over a continuous time axis: solar
69
44
  * (orange), grid (blue) and battery (green) areas around a zero baseline, with
@@ -71,10 +46,12 @@ function formatYTick(value) {
71
46
  * charging) dip below zero. For instantaneous power (day view). A legend at the
72
47
  * top toggles each series on/off; the y-axis rescales to the visible ones. visx.
73
48
  */
74
- function PowerHistoryChart({ data, unit, formatX, formatValue, labels, highlightAt, title, className, height = DEFAULT_HEIGHT }) {
49
+ function PowerHistoryChart({ data, unit, formatX, formatTooltipX, formatValue, labels, omitSeries, highlightAt, title, className, height = DEFAULT_HEIGHT }) {
50
+ const fmtTooltipX = formatTooltipX ?? formatX;
75
51
  const containerRef = useRef(null);
76
52
  const [width, setWidth] = useState(0);
77
53
  const [hiddenSeries, setHiddenSeries] = useState(() => /* @__PURE__ */ new Set());
54
+ const omitted = useMemo(() => new Set(omitSeries), [omitSeries]);
78
55
  const toggleSeries = useCallback((id) => {
79
56
  setHiddenSeries((prev) => {
80
57
  const next = new Set(prev);
@@ -83,7 +60,7 @@ function PowerHistoryChart({ data, unit, formatX, formatValue, labels, highlight
83
60
  return next;
84
61
  });
85
62
  }, []);
86
- const isHidden = useCallback((key) => hiddenSeries.has(key), [hiddenSeries]);
63
+ const isHidden = useCallback((key) => omitted.has(key) || hiddenSeries.has(key), [hiddenSeries, omitted]);
87
64
  const { showTooltip, hideTooltip, tooltipData, tooltipLeft, tooltipTop, tooltipOpen } = useTooltip();
88
65
  useEffect(() => {
89
66
  const element = containerRef.current;
@@ -103,13 +80,13 @@ function PowerHistoryChart({ data, unit, formatX, formatValue, labels, highlight
103
80
  let min = 0;
104
81
  let max = 0;
105
82
  for (const d of data) {
106
- if (!hiddenSeries.has("solar")) max = Math.max(max, d.solar);
107
- if (!hiddenSeries.has("consumption")) max = Math.max(max, d.consumption);
108
- if (!hiddenSeries.has("grid")) {
83
+ if (!isHidden("solar")) max = Math.max(max, d.solar);
84
+ if (!isHidden("consumption")) max = Math.max(max, d.consumption);
85
+ if (!isHidden("grid")) {
109
86
  max = Math.max(max, d.grid);
110
87
  min = Math.min(min, d.grid);
111
88
  }
112
- if (!hiddenSeries.has("battery")) {
89
+ if (!isHidden("battery")) {
113
90
  max = Math.max(max, d.battery);
114
91
  min = Math.min(min, d.battery);
115
92
  }
@@ -118,8 +95,8 @@ function PowerHistoryChart({ data, unit, formatX, formatValue, labels, highlight
118
95
  yMin: min,
119
96
  yMax: max
120
97
  };
121
- }, [data, hiddenSeries]);
122
- const { ticks: yTicks, lo, hi } = useMemo(() => buildAreaYTicks(yMin, yMax), [yMin, yMax]);
98
+ }, [data, isHidden]);
99
+ const { ticks: yTicks, min: lo, max: hi } = useMemo(() => buildYAxisScale(yMin, yMax), [yMin, yMax]);
123
100
  const plotRight = Math.max(Y_AXIS_GUTTER, width - PADDING_RIGHT);
124
101
  const xScale = useMemo(() => scaleLinear({
125
102
  domain: [tMin, tMax === tMin ? tMin + 1 : tMax],
@@ -160,7 +137,7 @@ function PowerHistoryChart({ data, unit, formatX, formatValue, labels, highlight
160
137
  showTooltip,
161
138
  xScale
162
139
  ]);
163
- const legendItems = SERIES_KEYS.map((key) => ({
140
+ const legendItems = SERIES_KEYS.filter((key) => !omitted.has(key)).map((key) => ({
164
141
  id: key,
165
142
  label: labels[key],
166
143
  color: SERIES_COLORS[key]
@@ -234,7 +211,7 @@ function PowerHistoryChart({ data, unit, formatX, formatValue, labels, highlight
234
211
  textAnchor: "end",
235
212
  fill: "var(--gray-a11)",
236
213
  fontSize: 10,
237
- children: formatYTick(tick)
214
+ children: formatYAxisTick(tick)
238
215
  })] }, `y-tick-${tick}`);
239
216
  }),
240
217
  AREA_KEYS.filter((key) => !isHidden(key)).map((key) => /* @__PURE__ */ jsx(Area, {
@@ -341,7 +318,7 @@ function PowerHistoryChart({ data, unit, formatX, formatValue, labels, highlight
341
318
  transition: "left 140ms ease-out, top 140ms ease-out, opacity 140ms ease-out"
342
319
  },
343
320
  children: tooltipData && /* @__PURE__ */ jsx(ChartTooltipCard, {
344
- label: formatX(tooltipData.t),
321
+ label: fmtTooltipX(tooltipData.t),
345
322
  rows: tooltipRows
346
323
  })
347
324
  })]
@@ -1 +1 @@
1
- {"version":3,"file":"power-history-chart.js","names":["valueFormat"],"sources":["../../src/components/power-history-chart.tsx"],"sourcesContent":["import { curveMonotoneX } from '@visx/curve'\nimport { localPoint } from '@visx/event'\nimport { scaleLinear } from '@visx/scale'\nimport { Area, LinePath } from '@visx/shape'\nimport { useTooltip } from '@visx/tooltip'\nimport { bisector } from '@visx/vendor/d3-array'\nimport * as React from 'react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { NARROW_NBSP } from '../utils/chars'\nimport { cn } from '../utils/cn'\nimport {\n integerFormat,\n oneDecimalFormat,\n twoDecimalFormat as valueFormat,\n} from '../utils/format'\nimport {\n ChartTooltipCard,\n type ChartTooltipRow,\n clampTooltipPosition,\n useMeasuredSize,\n} from './chart'\nimport { SeriesLegend, type SeriesLegendItem } from './series-legend'\nimport { SectionTitle } from './typography'\n\nexport interface PowerHistoryPoint {\n /** Epoch milliseconds. */\n t: number\n solar: number\n /** Signed: positive = import, negative = feed-in. */\n grid: number\n /** Signed: positive = discharge, negative = charge. */\n battery: number\n consumption: number\n}\n\nexport interface PowerHistoryChartProps {\n data: PowerHistoryPoint[]\n /** Axis/tooltip unit, e.g. \"kW\". */\n unit: string\n formatX: (t: number) => string\n formatValue?: (value: number) => string\n labels: {\n solar: string\n grid: string\n battery: string\n consumption: string\n }\n /** Epoch ms to mark with the current-time dashed line (omit to hide). */\n highlightAt?: number\n title?: string\n className?: string\n height?: number\n}\n\nconst AREA_KEYS = ['solar', 'grid', 'battery'] as const\n\n// All toggleable series, in legend order. The areas (solar/grid/battery) plus\n// the consumption overlay line.\nconst SERIES_KEYS = ['solar', 'grid', 'battery', 'consumption'] as const\ntype SeriesKey = (typeof SERIES_KEYS)[number]\n\nconst SERIES_COLORS = {\n solar: 'var(--orange-a9)',\n grid: 'var(--blue-9)',\n battery: 'var(--green-a9)',\n consumption: 'var(--gray-12)',\n} as const\n\nconst PADDING_TOP = 20\nconst PADDING_BOTTOM = 28\nconst PADDING_RIGHT = 8\nconst Y_AXIS_GUTTER = 34\nconst X_TICK_COUNT = 6\nconst DEFAULT_HEIGHT = 260\n\nconst tBisector = bisector<PowerHistoryPoint, number>((d) => d.t)\n\nfunction niceStep(rough: number) {\n const normalized = rough === 0 ? 1 : rough\n const magnitude = Math.pow(10, Math.floor(Math.log10(normalized)))\n const steps = [1, 2, 2.5, 5, 10]\n return (\n (steps.find((value) => value * magnitude >= normalized) ?? 10) * magnitude\n )\n}\n\n// Nice y ticks that always include zero, supporting negative values (feed-in /\n// battery charging dip below the baseline).\nfunction buildAreaYTicks(min: number, max: number) {\n const step = niceStep((max - min || 1) / 4)\n const lo = Math.floor(min / step) * step\n const hi = Math.ceil(max / step) * step\n const ticks: number[] = []\n for (let value = lo; value <= hi + step * 0.001; value += step) {\n ticks.push(Number(value.toFixed(6)))\n }\n return { ticks, lo, hi }\n}\n\nfunction formatYTick(value: number) {\n return Math.abs(value) >= 10\n ? integerFormat.format(value)\n : oneDecimalFormat.format(value)\n}\n\n/**\n * Diverging \"power sources\" area chart over a continuous time axis: solar\n * (orange), grid (blue) and battery (green) areas around a zero baseline, with\n * consumption overlaid as a line. Negative values (grid feed-in, battery\n * charging) dip below zero. For instantaneous power (day view). A legend at the\n * top toggles each series on/off; the y-axis rescales to the visible ones. visx.\n */\nexport function PowerHistoryChart({\n data,\n unit,\n formatX,\n formatValue,\n labels,\n highlightAt,\n title,\n className,\n height = DEFAULT_HEIGHT,\n}: PowerHistoryChartProps) {\n const containerRef = useRef<HTMLDivElement>(null)\n const [width, setWidth] = useState(0)\n const [hiddenSeries, setHiddenSeries] = useState<ReadonlySet<string>>(\n () => new Set(),\n )\n\n const toggleSeries = useCallback((id: string) => {\n setHiddenSeries((prev) => {\n const next = new Set(prev)\n if (next.has(id)) {\n next.delete(id)\n } else {\n next.add(id)\n }\n return next\n })\n }, [])\n\n const isHidden = useCallback(\n (key: SeriesKey) => hiddenSeries.has(key),\n [hiddenSeries],\n )\n\n const {\n showTooltip,\n hideTooltip,\n tooltipData,\n tooltipLeft,\n tooltipTop,\n tooltipOpen,\n } = useTooltip<PowerHistoryPoint>()\n\n useEffect(() => {\n const element = containerRef.current\n if (!element) return\n\n const resizeObserver = new ResizeObserver((entries) => {\n const entry = entries[0]\n if (!entry) return\n setWidth(entry.contentRect.width)\n })\n\n resizeObserver.observe(element)\n return () => resizeObserver.disconnect()\n }, [])\n\n const fmtValue = (value: number) =>\n formatValue\n ? formatValue(value)\n : `${valueFormat.format(value)}${NARROW_NBSP}${unit}`\n\n const tMin = data.length ? (data[0]?.t ?? 0) : 0\n const tMax = data.length ? (data[data.length - 1]?.t ?? 0) : 0\n\n const { yMin, yMax } = useMemo(() => {\n let min = 0\n let max = 0\n for (const d of data) {\n if (!hiddenSeries.has('solar')) max = Math.max(max, d.solar)\n if (!hiddenSeries.has('consumption')) max = Math.max(max, d.consumption)\n if (!hiddenSeries.has('grid')) {\n max = Math.max(max, d.grid)\n min = Math.min(min, d.grid)\n }\n if (!hiddenSeries.has('battery')) {\n max = Math.max(max, d.battery)\n min = Math.min(min, d.battery)\n }\n }\n return { yMin: min, yMax: max }\n }, [data, hiddenSeries])\n\n const {\n ticks: yTicks,\n lo,\n hi,\n } = useMemo(() => buildAreaYTicks(yMin, yMax), [yMin, yMax])\n\n const plotRight = Math.max(Y_AXIS_GUTTER, width - PADDING_RIGHT)\n\n const xScale = useMemo(\n () =>\n scaleLinear<number>({\n domain: [tMin, tMax === tMin ? tMin + 1 : tMax],\n range: [Y_AXIS_GUTTER, plotRight],\n }),\n [tMin, tMax, plotRight],\n )\n\n const yScale = useMemo(\n () =>\n scaleLinear<number>({\n domain: [lo, hi === lo ? lo + 1 : hi],\n range: [height - PADDING_BOTTOM, PADDING_TOP],\n }),\n [lo, hi, height],\n )\n\n const xTicks = useMemo(() => {\n if (tMax <= tMin) return [tMin]\n return Array.from(\n { length: X_TICK_COUNT },\n (_, i) => tMin + (i / (X_TICK_COUNT - 1)) * (tMax - tMin),\n )\n }, [tMin, tMax])\n\n const handleTooltip = useCallback(\n (\n event:\n | React.TouchEvent<SVGRectElement>\n | React.MouseEvent<SVGRectElement>,\n ) => {\n const point = localPoint(event)\n if (!point || data.length === 0) return\n\n const xMs = xScale.invert(point.x)\n const index = tBisector.left(data, xMs, 1)\n const d0 = data[index - 1]\n const d1 = data[index]\n if (!d0) return\n\n const d = d1 && xMs - d0.t > d1.t - xMs ? d1 : d0\n showTooltip({\n tooltipData: d,\n tooltipLeft: xScale(d.t),\n tooltipTop: PADDING_TOP,\n })\n },\n [data, showTooltip, xScale],\n )\n\n const legendItems: SeriesLegendItem[] = SERIES_KEYS.map((key) => ({\n id: key,\n label: labels[key],\n color: SERIES_COLORS[key],\n }))\n\n const tooltipRows: ChartTooltipRow[] = tooltipData\n ? SERIES_KEYS.filter((key) => !isHidden(key)).map((key) => ({\n key,\n label: labels[key],\n color: SERIES_COLORS[key],\n value: fmtValue(tooltipData[key]),\n }))\n : []\n\n const showHighlight =\n highlightAt !== undefined && highlightAt >= tMin && highlightAt <= tMax\n\n const [tooltipRef, tooltipSize] = useMeasuredSize<HTMLDivElement>()\n const tooltipPos = clampTooltipPosition({\n anchorX: tooltipLeft ?? 0,\n anchorY: tooltipTop ?? PADDING_TOP,\n width,\n height,\n tipWidth: tooltipSize.width,\n tipHeight: tooltipSize.height,\n placement: 'beside',\n })\n\n return (\n <div className={cn('flex w-full flex-col gap-2', className)}>\n {title && <SectionTitle>{title}</SectionTitle>}\n <SeriesLegend\n items={legendItems}\n hiddenIds={hiddenSeries}\n onToggle={toggleSeries}\n />\n <span className=\"px-1 text-xs text-gray-light\">{unit}</span>\n <div ref={containerRef} className=\"relative w-full\" style={{ height }}>\n {width > 0 && (\n <svg width={width} height={height}>\n <defs>\n {AREA_KEYS.map((key) => (\n <linearGradient\n key={key}\n id={`power-history-fill-${key}`}\n x1=\"0\"\n y1=\"0\"\n x2=\"0\"\n y2=\"1\"\n >\n <stop\n offset=\"0%\"\n stopColor={SERIES_COLORS[key]}\n stopOpacity={0.35}\n />\n <stop\n offset=\"100%\"\n stopColor={SERIES_COLORS[key]}\n stopOpacity={0.04}\n />\n </linearGradient>\n ))}\n </defs>\n\n {yTicks.map((tick) => {\n const y = yScale(tick)\n return (\n <g key={`y-tick-${tick}`}>\n <line\n x1={Y_AXIS_GUTTER}\n x2={plotRight}\n y1={y}\n y2={y}\n stroke=\"var(--gray-a4)\"\n strokeWidth={1}\n />\n <text\n x={Y_AXIS_GUTTER - 7}\n y={y + 3}\n textAnchor=\"end\"\n fill=\"var(--gray-a11)\"\n fontSize={10}\n >\n {formatYTick(tick)}\n </text>\n </g>\n )\n })}\n\n {AREA_KEYS.filter((key) => !isHidden(key)).map((key) => (\n <Area<PowerHistoryPoint>\n key={`area-${key}`}\n data={data}\n x={(d) => xScale(d.t)}\n y0={yScale(0)}\n y1={(d) => yScale(d[key])}\n curve={curveMonotoneX}\n fill={`url(#power-history-fill-${key})`}\n stroke=\"transparent\"\n />\n ))}\n\n {AREA_KEYS.filter((key) => !isHidden(key)).map((key) => (\n <LinePath<PowerHistoryPoint>\n key={`line-${key}`}\n data={data}\n x={(d) => xScale(d.t)}\n y={(d) => yScale(d[key])}\n curve={curveMonotoneX}\n stroke={SERIES_COLORS[key]}\n strokeWidth={1.5}\n fill=\"none\"\n />\n ))}\n\n {!isHidden('consumption') && (\n <LinePath<PowerHistoryPoint>\n data={data}\n x={(d) => xScale(d.t)}\n y={(d) => yScale(d.consumption)}\n curve={curveMonotoneX}\n stroke={SERIES_COLORS.consumption}\n strokeWidth={1.5}\n fill=\"none\"\n />\n )}\n\n <line\n x1={Y_AXIS_GUTTER}\n x2={plotRight}\n y1={yScale(0)}\n y2={yScale(0)}\n stroke=\"var(--slate-a6)\"\n strokeWidth={1}\n />\n\n {showHighlight && (\n <line\n x1={xScale(highlightAt)}\n x2={xScale(highlightAt)}\n y1={PADDING_TOP}\n y2={height - PADDING_BOTTOM}\n stroke=\"var(--spark-a9)\"\n strokeWidth={1.5}\n strokeDasharray=\"5 4\"\n pointerEvents=\"none\"\n />\n )}\n\n {xTicks.map((tick) => (\n <text\n key={`x-tick-${tick}`}\n x={xScale(tick)}\n y={height - PADDING_BOTTOM + 16}\n textAnchor=\"middle\"\n fill=\"var(--gray-a11)\"\n fontSize={10}\n >\n {formatX(tick)}\n </text>\n ))}\n\n {tooltipOpen && tooltipData && (\n <>\n <line\n x1={xScale(tooltipData.t)}\n x2={xScale(tooltipData.t)}\n y1={PADDING_TOP}\n y2={height - PADDING_BOTTOM}\n stroke=\"var(--slate-a6)\"\n strokeDasharray=\"4 4\"\n pointerEvents=\"none\"\n />\n {AREA_KEYS.filter((key) => !isHidden(key)).map((key) => (\n <circle\n key={`dot-${key}`}\n cx={xScale(tooltipData.t)}\n cy={yScale(tooltipData[key])}\n r={3.5}\n fill={SERIES_COLORS[key]}\n stroke=\"white\"\n strokeWidth={1.5}\n pointerEvents=\"none\"\n />\n ))}\n {!isHidden('consumption') && (\n <circle\n cx={xScale(tooltipData.t)}\n cy={yScale(tooltipData.consumption)}\n r={3.5}\n fill={SERIES_COLORS.consumption}\n stroke=\"white\"\n strokeWidth={1.5}\n pointerEvents=\"none\"\n />\n )}\n </>\n )}\n\n <rect\n x={Y_AXIS_GUTTER}\n y={PADDING_TOP}\n width={Math.max(0, plotRight - Y_AXIS_GUTTER)}\n height={Math.max(0, height - PADDING_BOTTOM - PADDING_TOP)}\n fill=\"transparent\"\n onMouseMove={handleTooltip}\n onMouseLeave={hideTooltip}\n onTouchMove={handleTooltip}\n onTouchEnd={hideTooltip}\n />\n </svg>\n )}\n\n <div\n ref={tooltipRef}\n className=\"pointer-events-none absolute z-10\"\n style={{\n left: tooltipPos.left,\n top: tooltipPos.top,\n opacity: tooltipOpen && tooltipData ? 1 : 0,\n transition:\n 'left 140ms ease-out, top 140ms ease-out, opacity 140ms ease-out',\n }}\n >\n {tooltipData && (\n <ChartTooltipCard\n label={formatX(tooltipData.t)}\n rows={tooltipRows}\n />\n )}\n </div>\n </div>\n </div>\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"power-history-chart.js","names":["valueFormat"],"sources":["../../src/components/power-history-chart.tsx"],"sourcesContent":["import { curveMonotoneX } from '@visx/curve'\nimport { localPoint } from '@visx/event'\nimport { scaleLinear } from '@visx/scale'\nimport { Area, LinePath } from '@visx/shape'\nimport { useTooltip } from '@visx/tooltip'\nimport { bisector } from '@visx/vendor/d3-array'\nimport * as React from 'react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { NARROW_NBSP } from '../utils/chars'\nimport { buildYAxisScale, formatYAxisTick } from '../utils/chart-scale'\nimport { cn } from '../utils/cn'\nimport { twoDecimalFormat as valueFormat } from '../utils/format'\nimport {\n ChartTooltipCard,\n type ChartTooltipRow,\n clampTooltipPosition,\n useMeasuredSize,\n} from './chart'\nimport { SeriesLegend, type SeriesLegendItem } from './series-legend'\nimport { SectionTitle } from './typography'\n\nexport interface PowerHistoryPoint {\n /** Epoch milliseconds. */\n t: number\n solar: number\n /** Signed: positive = import, negative = feed-in. */\n grid: number\n /** Signed: positive = discharge, negative = charge. */\n battery: number\n consumption: number\n}\n\nexport interface PowerHistoryChartProps {\n data: PowerHistoryPoint[]\n /** Axis/tooltip unit, e.g. \"kW\". */\n unit: string\n formatX: (t: number) => string\n /** Tooltip time formatter. Defaults to `formatX`; use to keep the tooltip's\n * full clock time when the axis ticks are compact (hour-only). */\n formatTooltipX?: (t: number) => string\n formatValue?: (value: number) => string\n labels: {\n solar: string\n grid: string\n battery: string\n consumption: string\n }\n /** Series to drop entirely (no legend entry, area, or tooltip row). */\n omitSeries?: readonly PowerHistorySeriesKey[]\n /** Epoch ms to mark with the current-time dashed line (omit to hide). */\n highlightAt?: number\n title?: string\n className?: string\n height?: number\n}\n\nconst AREA_KEYS = ['solar', 'grid', 'battery'] as const\n\n// All toggleable series, in legend order. The areas (solar/grid/battery) plus\n// the consumption overlay line.\nconst SERIES_KEYS = ['solar', 'grid', 'battery', 'consumption'] as const\nexport type PowerHistorySeriesKey = (typeof SERIES_KEYS)[number]\ntype SeriesKey = PowerHistorySeriesKey\n\nconst SERIES_COLORS = {\n solar: 'var(--orange-a9)',\n grid: 'var(--blue-9)',\n battery: 'var(--green-a9)',\n consumption: 'var(--gray-12)',\n} as const\n\nconst PADDING_TOP = 20\nconst PADDING_BOTTOM = 28\nconst PADDING_RIGHT = 8\nconst Y_AXIS_GUTTER = 34\nconst X_TICK_COUNT = 6\nconst DEFAULT_HEIGHT = 260\n\nconst tBisector = bisector<PowerHistoryPoint, number>((d) => d.t)\n\n/**\n * Diverging \"power sources\" area chart over a continuous time axis: solar\n * (orange), grid (blue) and battery (green) areas around a zero baseline, with\n * consumption overlaid as a line. Negative values (grid feed-in, battery\n * charging) dip below zero. For instantaneous power (day view). A legend at the\n * top toggles each series on/off; the y-axis rescales to the visible ones. visx.\n */\nexport function PowerHistoryChart({\n data,\n unit,\n formatX,\n formatTooltipX,\n formatValue,\n labels,\n omitSeries,\n highlightAt,\n title,\n className,\n height = DEFAULT_HEIGHT,\n}: PowerHistoryChartProps) {\n const fmtTooltipX = formatTooltipX ?? formatX\n const containerRef = useRef<HTMLDivElement>(null)\n const [width, setWidth] = useState(0)\n const [hiddenSeries, setHiddenSeries] = useState<ReadonlySet<string>>(\n () => new Set(),\n )\n const omitted = useMemo(() => new Set<SeriesKey>(omitSeries), [omitSeries])\n\n const toggleSeries = useCallback((id: string) => {\n setHiddenSeries((prev) => {\n const next = new Set(prev)\n if (next.has(id)) {\n next.delete(id)\n } else {\n next.add(id)\n }\n return next\n })\n }, [])\n\n const isHidden = useCallback(\n (key: SeriesKey) => omitted.has(key) || hiddenSeries.has(key),\n [hiddenSeries, omitted],\n )\n\n const {\n showTooltip,\n hideTooltip,\n tooltipData,\n tooltipLeft,\n tooltipTop,\n tooltipOpen,\n } = useTooltip<PowerHistoryPoint>()\n\n useEffect(() => {\n const element = containerRef.current\n if (!element) return\n\n const resizeObserver = new ResizeObserver((entries) => {\n const entry = entries[0]\n if (!entry) return\n setWidth(entry.contentRect.width)\n })\n\n resizeObserver.observe(element)\n return () => resizeObserver.disconnect()\n }, [])\n\n const fmtValue = (value: number) =>\n formatValue\n ? formatValue(value)\n : `${valueFormat.format(value)}${NARROW_NBSP}${unit}`\n\n const tMin = data.length ? (data[0]?.t ?? 0) : 0\n const tMax = data.length ? (data[data.length - 1]?.t ?? 0) : 0\n\n const { yMin, yMax } = useMemo(() => {\n let min = 0\n let max = 0\n for (const d of data) {\n if (!isHidden('solar')) max = Math.max(max, d.solar)\n if (!isHidden('consumption')) max = Math.max(max, d.consumption)\n if (!isHidden('grid')) {\n max = Math.max(max, d.grid)\n min = Math.min(min, d.grid)\n }\n if (!isHidden('battery')) {\n max = Math.max(max, d.battery)\n min = Math.min(min, d.battery)\n }\n }\n return { yMin: min, yMax: max }\n }, [data, isHidden])\n\n const {\n ticks: yTicks,\n min: lo,\n max: hi,\n } = useMemo(() => buildYAxisScale(yMin, yMax), [yMin, yMax])\n\n const plotRight = Math.max(Y_AXIS_GUTTER, width - PADDING_RIGHT)\n\n const xScale = useMemo(\n () =>\n scaleLinear<number>({\n domain: [tMin, tMax === tMin ? tMin + 1 : tMax],\n range: [Y_AXIS_GUTTER, plotRight],\n }),\n [tMin, tMax, plotRight],\n )\n\n const yScale = useMemo(\n () =>\n scaleLinear<number>({\n domain: [lo, hi === lo ? lo + 1 : hi],\n range: [height - PADDING_BOTTOM, PADDING_TOP],\n }),\n [lo, hi, height],\n )\n\n const xTicks = useMemo(() => {\n if (tMax <= tMin) return [tMin]\n return Array.from(\n { length: X_TICK_COUNT },\n (_, i) => tMin + (i / (X_TICK_COUNT - 1)) * (tMax - tMin),\n )\n }, [tMin, tMax])\n\n const handleTooltip = useCallback(\n (\n event:\n | React.TouchEvent<SVGRectElement>\n | React.MouseEvent<SVGRectElement>,\n ) => {\n const point = localPoint(event)\n if (!point || data.length === 0) return\n\n const xMs = xScale.invert(point.x)\n const index = tBisector.left(data, xMs, 1)\n const d0 = data[index - 1]\n const d1 = data[index]\n if (!d0) return\n\n const d = d1 && xMs - d0.t > d1.t - xMs ? d1 : d0\n showTooltip({\n tooltipData: d,\n tooltipLeft: xScale(d.t),\n tooltipTop: PADDING_TOP,\n })\n },\n [data, showTooltip, xScale],\n )\n\n const legendItems: SeriesLegendItem[] = SERIES_KEYS.filter(\n (key) => !omitted.has(key),\n ).map((key) => ({\n id: key,\n label: labels[key],\n color: SERIES_COLORS[key],\n }))\n\n const tooltipRows: ChartTooltipRow[] = tooltipData\n ? SERIES_KEYS.filter((key) => !isHidden(key)).map((key) => ({\n key,\n label: labels[key],\n color: SERIES_COLORS[key],\n value: fmtValue(tooltipData[key]),\n }))\n : []\n\n const showHighlight =\n highlightAt !== undefined && highlightAt >= tMin && highlightAt <= tMax\n\n const [tooltipRef, tooltipSize] = useMeasuredSize<HTMLDivElement>()\n const tooltipPos = clampTooltipPosition({\n anchorX: tooltipLeft ?? 0,\n anchorY: tooltipTop ?? PADDING_TOP,\n width,\n height,\n tipWidth: tooltipSize.width,\n tipHeight: tooltipSize.height,\n placement: 'beside',\n })\n\n return (\n <div className={cn('flex w-full flex-col gap-2', className)}>\n {title && <SectionTitle>{title}</SectionTitle>}\n <SeriesLegend\n items={legendItems}\n hiddenIds={hiddenSeries}\n onToggle={toggleSeries}\n />\n <span className=\"px-1 text-xs text-gray-light\">{unit}</span>\n <div ref={containerRef} className=\"relative w-full\" style={{ height }}>\n {width > 0 && (\n <svg width={width} height={height}>\n <defs>\n {AREA_KEYS.map((key) => (\n <linearGradient\n key={key}\n id={`power-history-fill-${key}`}\n x1=\"0\"\n y1=\"0\"\n x2=\"0\"\n y2=\"1\"\n >\n <stop\n offset=\"0%\"\n stopColor={SERIES_COLORS[key]}\n stopOpacity={0.35}\n />\n <stop\n offset=\"100%\"\n stopColor={SERIES_COLORS[key]}\n stopOpacity={0.04}\n />\n </linearGradient>\n ))}\n </defs>\n\n {yTicks.map((tick) => {\n const y = yScale(tick)\n return (\n <g key={`y-tick-${tick}`}>\n <line\n x1={Y_AXIS_GUTTER}\n x2={plotRight}\n y1={y}\n y2={y}\n stroke=\"var(--gray-a4)\"\n strokeWidth={1}\n />\n <text\n x={Y_AXIS_GUTTER - 7}\n y={y + 3}\n textAnchor=\"end\"\n fill=\"var(--gray-a11)\"\n fontSize={10}\n >\n {formatYAxisTick(tick)}\n </text>\n </g>\n )\n })}\n\n {AREA_KEYS.filter((key) => !isHidden(key)).map((key) => (\n <Area<PowerHistoryPoint>\n key={`area-${key}`}\n data={data}\n x={(d) => xScale(d.t)}\n y0={yScale(0)}\n y1={(d) => yScale(d[key])}\n curve={curveMonotoneX}\n fill={`url(#power-history-fill-${key})`}\n stroke=\"transparent\"\n />\n ))}\n\n {AREA_KEYS.filter((key) => !isHidden(key)).map((key) => (\n <LinePath<PowerHistoryPoint>\n key={`line-${key}`}\n data={data}\n x={(d) => xScale(d.t)}\n y={(d) => yScale(d[key])}\n curve={curveMonotoneX}\n stroke={SERIES_COLORS[key]}\n strokeWidth={1.5}\n fill=\"none\"\n />\n ))}\n\n {!isHidden('consumption') && (\n <LinePath<PowerHistoryPoint>\n data={data}\n x={(d) => xScale(d.t)}\n y={(d) => yScale(d.consumption)}\n curve={curveMonotoneX}\n stroke={SERIES_COLORS.consumption}\n strokeWidth={1.5}\n fill=\"none\"\n />\n )}\n\n <line\n x1={Y_AXIS_GUTTER}\n x2={plotRight}\n y1={yScale(0)}\n y2={yScale(0)}\n stroke=\"var(--slate-a6)\"\n strokeWidth={1}\n />\n\n {showHighlight && (\n <line\n x1={xScale(highlightAt)}\n x2={xScale(highlightAt)}\n y1={PADDING_TOP}\n y2={height - PADDING_BOTTOM}\n stroke=\"var(--spark-a9)\"\n strokeWidth={1.5}\n strokeDasharray=\"5 4\"\n pointerEvents=\"none\"\n />\n )}\n\n {xTicks.map((tick) => (\n <text\n key={`x-tick-${tick}`}\n x={xScale(tick)}\n y={height - PADDING_BOTTOM + 16}\n textAnchor=\"middle\"\n fill=\"var(--gray-a11)\"\n fontSize={10}\n >\n {formatX(tick)}\n </text>\n ))}\n\n {tooltipOpen && tooltipData && (\n <>\n <line\n x1={xScale(tooltipData.t)}\n x2={xScale(tooltipData.t)}\n y1={PADDING_TOP}\n y2={height - PADDING_BOTTOM}\n stroke=\"var(--slate-a6)\"\n strokeDasharray=\"4 4\"\n pointerEvents=\"none\"\n />\n {AREA_KEYS.filter((key) => !isHidden(key)).map((key) => (\n <circle\n key={`dot-${key}`}\n cx={xScale(tooltipData.t)}\n cy={yScale(tooltipData[key])}\n r={3.5}\n fill={SERIES_COLORS[key]}\n stroke=\"white\"\n strokeWidth={1.5}\n pointerEvents=\"none\"\n />\n ))}\n {!isHidden('consumption') && (\n <circle\n cx={xScale(tooltipData.t)}\n cy={yScale(tooltipData.consumption)}\n r={3.5}\n fill={SERIES_COLORS.consumption}\n stroke=\"white\"\n strokeWidth={1.5}\n pointerEvents=\"none\"\n />\n )}\n </>\n )}\n\n <rect\n x={Y_AXIS_GUTTER}\n y={PADDING_TOP}\n width={Math.max(0, plotRight - Y_AXIS_GUTTER)}\n height={Math.max(0, height - PADDING_BOTTOM - PADDING_TOP)}\n fill=\"transparent\"\n onMouseMove={handleTooltip}\n onMouseLeave={hideTooltip}\n onTouchMove={handleTooltip}\n onTouchEnd={hideTooltip}\n />\n </svg>\n )}\n\n <div\n ref={tooltipRef}\n className=\"pointer-events-none absolute z-10\"\n style={{\n left: tooltipPos.left,\n top: tooltipPos.top,\n opacity: tooltipOpen && tooltipData ? 1 : 0,\n transition:\n 'left 140ms ease-out, top 140ms ease-out, opacity 140ms ease-out',\n }}\n >\n {tooltipData && (\n <ChartTooltipCard\n label={fmtTooltipX(tooltipData.t)}\n rows={tooltipRows}\n />\n )}\n </div>\n </div>\n </div>\n )\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAwDA,MAAM,YAAY;CAAC;CAAS;CAAQ;CAAU;AAI9C,MAAM,cAAc;CAAC;CAAS;CAAQ;CAAW;CAAc;AAI/D,MAAM,gBAAgB;CACpB,OAAO;CACP,MAAM;CACN,SAAS;CACT,aAAa;CACd;AAED,MAAM,cAAc;AACpB,MAAM,iBAAiB;AACvB,MAAM,gBAAgB;AACtB,MAAM,gBAAgB;AACtB,MAAM,eAAe;AACrB,MAAM,iBAAiB;AAEvB,MAAM,YAAY,UAAqC,MAAM,EAAE,EAAE;;;;;;;;AASjE,SAAgB,kBAAkB,EAChC,MACA,MACA,SACA,gBACA,aACA,QACA,YACA,aACA,OACA,WACA,SAAS,kBACgB;CACzB,MAAM,cAAc,kBAAkB;CACtC,MAAM,eAAe,OAAuB,KAAK;CACjD,MAAM,CAAC,OAAO,YAAY,SAAS,EAAE;CACrC,MAAM,CAAC,cAAc,mBAAmB,+BAChC,IAAI,KAAK,CAChB;CACD,MAAM,UAAU,cAAc,IAAI,IAAe,WAAW,EAAE,CAAC,WAAW,CAAC;CAE3E,MAAM,eAAe,aAAa,OAAe;AAC/C,mBAAiB,SAAS;GACxB,MAAM,OAAO,IAAI,IAAI,KAAK;AAC1B,OAAI,KAAK,IAAI,GAAG,CACd,MAAK,OAAO,GAAG;OAEf,MAAK,IAAI,GAAG;AAEd,UAAO;IACP;IACD,EAAE,CAAC;CAEN,MAAM,WAAW,aACd,QAAmB,QAAQ,IAAI,IAAI,IAAI,aAAa,IAAI,IAAI,EAC7D,CAAC,cAAc,QAAQ,CACxB;CAED,MAAM,EACJ,aACA,aACA,aACA,aACA,YACA,gBACE,YAA+B;AAEnC,iBAAgB;EACd,MAAM,UAAU,aAAa;AAC7B,MAAI,CAAC,QAAS;EAEd,MAAM,iBAAiB,IAAI,gBAAgB,YAAY;GACrD,MAAM,QAAQ,QAAQ;AACtB,OAAI,CAAC,MAAO;AACZ,YAAS,MAAM,YAAY,MAAM;IACjC;AAEF,iBAAe,QAAQ,QAAQ;AAC/B,eAAa,eAAe,YAAY;IACvC,EAAE,CAAC;CAEN,MAAM,YAAY,UAChB,cACI,YAAY,MAAM,GAClB,GAAGA,iBAAY,OAAO,MAAM,IAAiB;CAEnD,MAAM,OAAO,KAAK,SAAU,KAAK,IAAI,KAAK,IAAK;CAC/C,MAAM,OAAO,KAAK,SAAU,KAAK,KAAK,SAAS,IAAI,KAAK,IAAK;CAE7D,MAAM,EAAE,MAAM,SAAS,cAAc;EACnC,IAAI,MAAM;EACV,IAAI,MAAM;AACV,OAAK,MAAM,KAAK,MAAM;AACpB,OAAI,CAAC,SAAS,QAAQ,CAAE,OAAM,KAAK,IAAI,KAAK,EAAE,MAAM;AACpD,OAAI,CAAC,SAAS,cAAc,CAAE,OAAM,KAAK,IAAI,KAAK,EAAE,YAAY;AAChE,OAAI,CAAC,SAAS,OAAO,EAAE;AACrB,UAAM,KAAK,IAAI,KAAK,EAAE,KAAK;AAC3B,UAAM,KAAK,IAAI,KAAK,EAAE,KAAK;;AAE7B,OAAI,CAAC,SAAS,UAAU,EAAE;AACxB,UAAM,KAAK,IAAI,KAAK,EAAE,QAAQ;AAC9B,UAAM,KAAK,IAAI,KAAK,EAAE,QAAQ;;;AAGlC,SAAO;GAAE,MAAM;GAAK,MAAM;GAAK;IAC9B,CAAC,MAAM,SAAS,CAAC;CAEpB,MAAM,EACJ,OAAO,QACP,KAAK,IACL,KAAK,OACH,cAAc,gBAAgB,MAAM,KAAK,EAAE,CAAC,MAAM,KAAK,CAAC;CAE5D,MAAM,YAAY,KAAK,IAAI,eAAe,QAAQ,cAAc;CAEhE,MAAM,SAAS,cAEX,YAAoB;EAClB,QAAQ,CAAC,MAAM,SAAS,OAAO,OAAO,IAAI,KAAK;EAC/C,OAAO,CAAC,eAAe,UAAU;EAClC,CAAC,EACJ;EAAC;EAAM;EAAM;EAAU,CACxB;CAED,MAAM,SAAS,cAEX,YAAoB;EAClB,QAAQ,CAAC,IAAI,OAAO,KAAK,KAAK,IAAI,GAAG;EACrC,OAAO,CAAC,SAAS,gBAAgB,YAAY;EAC9C,CAAC,EACJ;EAAC;EAAI;EAAI;EAAO,CACjB;CAED,MAAM,SAAS,cAAc;AAC3B,MAAI,QAAQ,KAAM,QAAO,CAAC,KAAK;AAC/B,SAAO,MAAM,KACX,EAAE,QAAQ,cAAc,GACvB,GAAG,MAAM,OAAQ,KAAK,eAAe,MAAO,OAAO,MACrD;IACA,CAAC,MAAM,KAAK,CAAC;CAEhB,MAAM,gBAAgB,aAElB,UAGG;EACH,MAAM,QAAQ,WAAW,MAAM;AAC/B,MAAI,CAAC,SAAS,KAAK,WAAW,EAAG;EAEjC,MAAM,MAAM,OAAO,OAAO,MAAM,EAAE;EAClC,MAAM,QAAQ,UAAU,KAAK,MAAM,KAAK,EAAE;EAC1C,MAAM,KAAK,KAAK,QAAQ;EACxB,MAAM,KAAK,KAAK;AAChB,MAAI,CAAC,GAAI;EAET,MAAM,IAAI,MAAM,MAAM,GAAG,IAAI,GAAG,IAAI,MAAM,KAAK;AAC/C,cAAY;GACV,aAAa;GACb,aAAa,OAAO,EAAE,EAAE;GACxB,YAAY;GACb,CAAC;IAEJ;EAAC;EAAM;EAAa;EAAO,CAC5B;CAED,MAAM,cAAkC,YAAY,QACjD,QAAQ,CAAC,QAAQ,IAAI,IAAI,CAC3B,CAAC,KAAK,SAAS;EACd,IAAI;EACJ,OAAO,OAAO;EACd,OAAO,cAAc;EACtB,EAAE;CAEH,MAAM,cAAiC,cACnC,YAAY,QAAQ,QAAQ,CAAC,SAAS,IAAI,CAAC,CAAC,KAAK,SAAS;EACxD;EACA,OAAO,OAAO;EACd,OAAO,cAAc;EACrB,OAAO,SAAS,YAAY,KAAK;EAClC,EAAE,GACH,EAAE;CAEN,MAAM,gBACJ,gBAAgB,KAAA,KAAa,eAAe,QAAQ,eAAe;CAErE,MAAM,CAAC,YAAY,eAAe,iBAAiC;CACnE,MAAM,aAAa,qBAAqB;EACtC,SAAS,eAAe;EACxB,SAAS,cAAc;EACvB;EACA;EACA,UAAU,YAAY;EACtB,WAAW,YAAY;EACvB,WAAW;EACZ,CAAC;AAEF,QACE,qBAAC,OAAD;EAAK,WAAW,GAAG,8BAA8B,UAAU;YAA3D;GACG,SAAS,oBAAC,cAAD,EAAA,UAAe,OAAqB,CAAA;GAC9C,oBAAC,cAAD;IACE,OAAO;IACP,WAAW;IACX,UAAU;IACV,CAAA;GACF,oBAAC,QAAD;IAAM,WAAU;cAAgC;IAAY,CAAA;GAC5D,qBAAC,OAAD;IAAK,KAAK;IAAc,WAAU;IAAkB,OAAO,EAAE,QAAQ;cAArE,CACG,QAAQ,KACP,qBAAC,OAAD;KAAY;KAAe;eAA3B;MACE,oBAAC,QAAD,EAAA,UACG,UAAU,KAAK,QACd,qBAAC,kBAAD;OAEE,IAAI,sBAAsB;OAC1B,IAAG;OACH,IAAG;OACH,IAAG;OACH,IAAG;iBANL,CAQE,oBAAC,QAAD;QACE,QAAO;QACP,WAAW,cAAc;QACzB,aAAa;QACb,CAAA,EACF,oBAAC,QAAD;QACE,QAAO;QACP,WAAW,cAAc;QACzB,aAAa;QACb,CAAA,CACa;SAjBV,IAiBU,CACjB,EACG,CAAA;MAEN,OAAO,KAAK,SAAS;OACpB,MAAM,IAAI,OAAO,KAAK;AACtB,cACE,qBAAC,KAAD,EAAA,UAAA,CACE,oBAAC,QAAD;QACE,IAAI;QACJ,IAAI;QACJ,IAAI;QACJ,IAAI;QACJ,QAAO;QACP,aAAa;QACb,CAAA,EACF,oBAAC,QAAD;QACE,GAAG,gBAAgB;QACnB,GAAG,IAAI;QACP,YAAW;QACX,MAAK;QACL,UAAU;kBAET,gBAAgB,KAAK;QACjB,CAAA,CACL,EAAA,EAlBI,UAAU,OAkBd;QAEN;MAED,UAAU,QAAQ,QAAQ,CAAC,SAAS,IAAI,CAAC,CAAC,KAAK,QAC9C,oBAAC,MAAD;OAEQ;OACN,IAAI,MAAM,OAAO,EAAE,EAAE;OACrB,IAAI,OAAO,EAAE;OACb,KAAK,MAAM,OAAO,EAAE,KAAK;OACzB,OAAO;OACP,MAAM,2BAA2B,IAAI;OACrC,QAAO;OACP,EARK,QAAQ,MAQb,CACF;MAED,UAAU,QAAQ,QAAQ,CAAC,SAAS,IAAI,CAAC,CAAC,KAAK,QAC9C,oBAAC,UAAD;OAEQ;OACN,IAAI,MAAM,OAAO,EAAE,EAAE;OACrB,IAAI,MAAM,OAAO,EAAE,KAAK;OACxB,OAAO;OACP,QAAQ,cAAc;OACtB,aAAa;OACb,MAAK;OACL,EARK,QAAQ,MAQb,CACF;MAED,CAAC,SAAS,cAAc,IACvB,oBAAC,UAAD;OACQ;OACN,IAAI,MAAM,OAAO,EAAE,EAAE;OACrB,IAAI,MAAM,OAAO,EAAE,YAAY;OAC/B,OAAO;OACP,QAAQ,cAAc;OACtB,aAAa;OACb,MAAK;OACL,CAAA;MAGJ,oBAAC,QAAD;OACE,IAAI;OACJ,IAAI;OACJ,IAAI,OAAO,EAAE;OACb,IAAI,OAAO,EAAE;OACb,QAAO;OACP,aAAa;OACb,CAAA;MAED,iBACC,oBAAC,QAAD;OACE,IAAI,OAAO,YAAY;OACvB,IAAI,OAAO,YAAY;OACvB,IAAI;OACJ,IAAI,SAAS;OACb,QAAO;OACP,aAAa;OACb,iBAAgB;OAChB,eAAc;OACd,CAAA;MAGH,OAAO,KAAK,SACX,oBAAC,QAAD;OAEE,GAAG,OAAO,KAAK;OACf,GAAG,SAAS,iBAAiB;OAC7B,YAAW;OACX,MAAK;OACL,UAAU;iBAET,QAAQ,KAAK;OACT,EARA,UAAU,OAQV,CACP;MAED,eAAe,eACd,qBAAA,UAAA,EAAA,UAAA;OACE,oBAAC,QAAD;QACE,IAAI,OAAO,YAAY,EAAE;QACzB,IAAI,OAAO,YAAY,EAAE;QACzB,IAAI;QACJ,IAAI,SAAS;QACb,QAAO;QACP,iBAAgB;QAChB,eAAc;QACd,CAAA;OACD,UAAU,QAAQ,QAAQ,CAAC,SAAS,IAAI,CAAC,CAAC,KAAK,QAC9C,oBAAC,UAAD;QAEE,IAAI,OAAO,YAAY,EAAE;QACzB,IAAI,OAAO,YAAY,KAAK;QAC5B,GAAG;QACH,MAAM,cAAc;QACpB,QAAO;QACP,aAAa;QACb,eAAc;QACd,EARK,OAAO,MAQZ,CACF;OACD,CAAC,SAAS,cAAc,IACvB,oBAAC,UAAD;QACE,IAAI,OAAO,YAAY,EAAE;QACzB,IAAI,OAAO,YAAY,YAAY;QACnC,GAAG;QACH,MAAM,cAAc;QACpB,QAAO;QACP,aAAa;QACb,eAAc;QACd,CAAA;OAEH,EAAA,CAAA;MAGL,oBAAC,QAAD;OACE,GAAG;OACH,GAAG;OACH,OAAO,KAAK,IAAI,GAAG,YAAY,cAAc;OAC7C,QAAQ,KAAK,IAAI,GAAG,SAAS,iBAAiB,YAAY;OAC1D,MAAK;OACL,aAAa;OACb,cAAc;OACd,aAAa;OACb,YAAY;OACZ,CAAA;MACE;QAGR,oBAAC,OAAD;KACE,KAAK;KACL,WAAU;KACV,OAAO;MACL,MAAM,WAAW;MACjB,KAAK,WAAW;MAChB,SAAS,eAAe,cAAc,IAAI;MAC1C,YACE;MACH;eAEA,eACC,oBAAC,kBAAD;MACE,OAAO,YAAY,YAAY,EAAE;MACjC,MAAM;MACN,CAAA;KAEA,CAAA,CACF;;GACF"}
@@ -13,6 +13,7 @@ const SANKEY_CATEGORY_COLORS = {
13
13
  solar: "var(--orange-a9)",
14
14
  battery: "var(--green-a9)",
15
15
  grid: "var(--blue-9)",
16
+ gridFeedIn: "var(--blue-7)",
16
17
  home: "var(--spark-9)",
17
18
  floor: "var(--gray-a9)",
18
19
  device: "var(--gray-a8)"
@@ -32,49 +33,12 @@ const LABEL_VALUE_GAP = -4;
32
33
  /** Height of the value box rendered below the node name. */
33
34
  const VALUE_HEIGHT = 16;
34
35
  const DEFAULT_COLOR = "var(--gray-a9)";
35
- const ANIMATION_DURATION_MS = 500;
36
36
  function resolveNodeColor(node, categoryColors, defaultColor) {
37
37
  if (node.color) return node.color;
38
38
  if (node.category && categoryColors?.[node.category]) return categoryColors[node.category];
39
39
  return defaultColor;
40
40
  }
41
- function easeOutCubic(t) {
42
- return 1 - (1 - t) ** 3;
43
- }
44
- function linkKey(link) {
45
- return `${link.source}-${link.target}`;
46
- }
47
- /**
48
- * Interpolates link values toward `links` over ~500ms so visx re-layouts each
49
- * frame, smoothly tweening node rects and link paths.
50
- */
51
- function useAnimatedLinks(links) {
52
- const [animated, setAnimated] = (0, react.useState)(links);
53
- const animatedRef = (0, react.useRef)(links);
54
- (0, react.useEffect)(() => {
55
- const fromByKey = new Map(animatedRef.current.map((l) => [linkKey(l), l]));
56
- const start = performance.now();
57
- let raf = 0;
58
- const tick = (now) => {
59
- const t = Math.min(1, (now - start) / ANIMATION_DURATION_MS);
60
- const eased = easeOutCubic(t);
61
- const next = links.map((toLink) => {
62
- const fromValue = fromByKey.get(linkKey(toLink))?.value ?? toLink.value;
63
- return {
64
- ...toLink,
65
- value: Math.round(fromValue + (toLink.value - fromValue) * eased)
66
- };
67
- });
68
- animatedRef.current = next;
69
- setAnimated(next);
70
- if (t < 1) raf = requestAnimationFrame(tick);
71
- };
72
- raf = requestAnimationFrame(tick);
73
- return () => cancelAnimationFrame(raf);
74
- }, [links]);
75
- return animated;
76
- }
77
- function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, defaultColor = DEFAULT_COLOR, valueSuffix, valueFormat, height = DEFAULT_HEIGHT, nodeWidth = DEFAULT_NODE_WIDTH, nodePadding = DEFAULT_NODE_PADDING, labelPadding = DEFAULT_LABEL_PADDING, title, className }) {
41
+ function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, defaultColor = DEFAULT_COLOR, valueSuffix, valueFormat, height = DEFAULT_HEIGHT, nodeWidth = DEFAULT_NODE_WIDTH, nodePadding = DEFAULT_NODE_PADDING, labelPadding = DEFAULT_LABEL_PADDING, keepAtDepthIds, title, className }) {
78
42
  const containerRef = (0, react.useRef)(null);
79
43
  const [width, setWidth] = (0, react.useState)(0);
80
44
  (0, react.useEffect)(() => {
@@ -88,9 +52,8 @@ function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, de
88
52
  resizeObserver.observe(element);
89
53
  return () => resizeObserver.disconnect();
90
54
  }, []);
91
- const animatedLinks = useAnimatedLinks(links);
92
55
  const nodeIds = (0, react.useMemo)(() => new Set(nodes.map((node) => node.id)), [nodes]);
93
- const visibleLinks = (0, react.useMemo)(() => animatedLinks.filter((link) => link.value > 0 && nodeIds.has(link.source) && nodeIds.has(link.target)), [animatedLinks, nodeIds]);
56
+ const visibleLinks = (0, react.useMemo)(() => links.filter((link) => link.value > 0 && nodeIds.has(link.source) && nodeIds.has(link.target)), [links, nodeIds]);
94
57
  const visibleNodes = (0, react.useMemo)(() => {
95
58
  const referencedIds = /* @__PURE__ */ new Set();
96
59
  for (const link of visibleLinks) {
@@ -118,6 +81,10 @@ function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, de
118
81
  nodes: visibleNodes.map((node) => ({ ...node })),
119
82
  links: visibleLinks.map((link) => ({ ...link }))
120
83
  }), [visibleNodes, visibleLinks]);
84
+ const nodeAlign = (0, react.useMemo)(() => {
85
+ const keep = new Set(keepAtDepthIds);
86
+ return (node, n) => keep.has(node.id) ? node.depth ?? 0 : (0, _visx_sankey.sankeyJustify)(node, n);
87
+ }, [keepAtDepthIds]);
121
88
  const innerWidth = Math.max(0, width - labelPadding - LEFT_INSET);
122
89
  const innerHeight = Math.max(0, height - TOP_INSET * 2);
123
90
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -137,7 +104,7 @@ function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, de
137
104
  size: [innerWidth, innerHeight],
138
105
  nodeWidth,
139
106
  nodePadding,
140
- nodeAlign: _visx_sankey.sankeyJustify,
107
+ nodeAlign,
141
108
  nodeId: (node) => node.id,
142
109
  nodeSort,
143
110
  children: ({ graph: laidOut, createPath }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_visx_group.Group, {
@@ -1 +1 @@
1
- {"version":3,"file":"sankey-chart.cjs","names":["SectionTitle","Group","Sankey","sankeyJustify","NumberFlow"],"sources":["../../src/components/sankey-chart.tsx"],"sourcesContent":["import { Group } from '@visx/group'\nimport {\n Sankey,\n sankeyJustify,\n type SankeyLink,\n type SankeyNode,\n} from '@visx/sankey'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '../utils/cn'\nimport NumberFlow, { type Format } from './number-flow'\nimport { SectionTitle } from './typography'\n\nconst SANKEY_CATEGORY_COLORS: Record<string, string> = {\n solar: 'var(--orange-a9)',\n battery: 'var(--green-a9)',\n grid: 'var(--blue-9)',\n home: 'var(--spark-9)',\n floor: 'var(--gray-a9)',\n device: 'var(--gray-a8)',\n}\n\nexport interface SankeyChartNode {\n id: string\n label: string\n /** Optional category — used to colour the node and any outgoing links. */\n category?: string\n /** Override the colour resolved from `category`. */\n color?: string\n}\n\nexport interface SankeyChartLink {\n source: string\n target: string\n value: number\n /** Override the colour of the link. Defaults to the source node colour. */\n color?: string\n}\n\nexport interface SankeyChartProps {\n nodes: SankeyChartNode[]\n links: SankeyChartLink[]\n /** Resolves a category to a colour. */\n categoryColors?: Record<string, string>\n /** Fallback colour for nodes/links without a category. */\n defaultColor?: string\n /** Suffix appended to each animated value label (e.g. \" W\"). */\n valueSuffix?: string\n /** NumberFlow `format` for value labels. */\n valueFormat?: Format\n height?: number\n nodeWidth?: number\n nodePadding?: number\n /** Reserved horizontal space on the right for labels. */\n labelPadding?: number\n title?: string\n className?: string\n}\n\ntype SankeyDatumNode = SankeyChartNode & {\n /** Computed by d3-sankey; present after layout. */\n}\n\ntype SankeyDatumLink = SankeyChartLink\n\nconst DEFAULT_HEIGHT = 260\nconst DEFAULT_NODE_WIDTH = 12\nconst DEFAULT_NODE_PADDING = 10\nconst DEFAULT_LABEL_PADDING = 96\nconst LEFT_INSET = 4\nconst TOP_INSET = 8\nconst LABEL_GAP = 8\n/** Approx. cap/ascender height of the node name above its alphabetic baseline. */\nconst LABEL_NAME_HEIGHT = 11\n/** Vertical offset of the value box relative to the name baseline. Negative pulls\n * the value up toward the name to tighten the gap between them. */\nconst LABEL_VALUE_GAP = -4\n/** Height of the value box rendered below the node name. */\nconst VALUE_HEIGHT = 16\nconst DEFAULT_COLOR = 'var(--gray-a9)'\nconst ANIMATION_DURATION_MS = 500\n\nfunction resolveNodeColor(\n node: SankeyChartNode,\n categoryColors: Record<string, string> | undefined,\n defaultColor: string,\n) {\n if (node.color) return node.color\n if (node.category && categoryColors?.[node.category]) {\n return categoryColors[node.category] as string\n }\n return defaultColor\n}\n\nfunction easeOutCubic(t: number) {\n return 1 - (1 - t) ** 3\n}\n\nfunction linkKey(link: SankeyChartLink) {\n return `${link.source}-${link.target}`\n}\n\n/**\n * Interpolates link values toward `links` over ~500ms so visx re-layouts each\n * frame, smoothly tweening node rects and link paths.\n */\nfunction useAnimatedLinks(links: SankeyChartLink[]): SankeyChartLink[] {\n const [animated, setAnimated] = useState<SankeyChartLink[]>(links)\n const animatedRef = useRef<SankeyChartLink[]>(links)\n\n useEffect(() => {\n const fromByKey = new Map(animatedRef.current.map((l) => [linkKey(l), l]))\n const start = performance.now()\n let raf = 0\n\n const tick = (now: number) => {\n const t = Math.min(1, (now - start) / ANIMATION_DURATION_MS)\n const eased = easeOutCubic(t)\n const next = links.map((toLink) => {\n const fromValue = fromByKey.get(linkKey(toLink))?.value ?? toLink.value\n return {\n ...toLink,\n value: Math.round(fromValue + (toLink.value - fromValue) * eased),\n }\n })\n animatedRef.current = next\n setAnimated(next)\n if (t < 1) raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n return () => cancelAnimationFrame(raf)\n }, [links])\n\n return animated\n}\n\nfunction SankeyChart({\n nodes,\n links,\n categoryColors = SANKEY_CATEGORY_COLORS,\n defaultColor = DEFAULT_COLOR,\n valueSuffix,\n valueFormat,\n height = DEFAULT_HEIGHT,\n nodeWidth = DEFAULT_NODE_WIDTH,\n nodePadding = DEFAULT_NODE_PADDING,\n labelPadding = DEFAULT_LABEL_PADDING,\n title,\n className,\n}: SankeyChartProps): React.ReactElement {\n const containerRef = useRef<HTMLDivElement>(null)\n const [width, setWidth] = useState(0)\n\n useEffect(() => {\n const element = containerRef.current\n if (!element) return\n\n const resizeObserver = new ResizeObserver((entries) => {\n const entry = entries[0]\n if (!entry) return\n setWidth(entry.contentRect.width)\n })\n\n resizeObserver.observe(element)\n return () => resizeObserver.disconnect()\n }, [])\n\n const animatedLinks = useAnimatedLinks(links)\n\n const nodeIds = useMemo(() => new Set(nodes.map((node) => node.id)), [nodes])\n\n // Drop links whose endpoints aren't in the current node set. `animatedLinks`\n // lags `nodes` by a frame while tweening, so during a node add/remove a link\n // can momentarily reference a node that no longer exists — which makes\n // d3-sankey throw `missing: <id>`.\n const visibleLinks = useMemo(\n () =>\n animatedLinks.filter(\n (link) =>\n link.value > 0 &&\n nodeIds.has(link.source) &&\n nodeIds.has(link.target),\n ),\n [animatedLinks, nodeIds],\n )\n\n const visibleNodes = useMemo(() => {\n const referencedIds = new Set<string>()\n for (const link of visibleLinks) {\n referencedIds.add(link.source)\n referencedIds.add(link.target)\n }\n return nodes.filter((node) => referencedIds.has(node.id))\n }, [nodes, visibleLinks])\n\n const colorById = useMemo(() => {\n const map = new Map<string, string>()\n for (const node of nodes) {\n map.set(node.id, resolveNodeColor(node, categoryColors, defaultColor))\n }\n return map\n }, [categoryColors, defaultColor, nodes])\n\n const orderById = useMemo(() => {\n const map = new Map<string, number>()\n visibleNodes.forEach((node, index) => map.set(node.id, index))\n return map\n }, [visibleNodes])\n\n const nodeSort = useMemo(\n () =>\n (\n a: SankeyNode<SankeyDatumNode, SankeyDatumLink>,\n b: SankeyNode<SankeyDatumNode, SankeyDatumLink>,\n ) =>\n (orderById.get(a.id) ?? 0) - (orderById.get(b.id) ?? 0),\n [orderById],\n )\n\n const graph = useMemo(\n () => ({\n nodes: visibleNodes.map((node) => ({ ...node })),\n links: visibleLinks.map((link) => ({ ...link })),\n }),\n [visibleNodes, visibleLinks],\n )\n\n const innerWidth = Math.max(0, width - labelPadding - LEFT_INSET)\n const innerHeight = Math.max(0, height - TOP_INSET * 2)\n\n return (\n <div className={cn('flex w-full flex-col gap-2 py-2', className)}>\n {title && <SectionTitle>{title}</SectionTitle>}\n\n <div ref={containerRef} className=\"w-full\" style={{ height }}>\n {width > 0 && innerWidth > 0 && (\n <svg width={width} height={height}>\n <Group left={LEFT_INSET} top={TOP_INSET}>\n <Sankey<SankeyDatumNode, SankeyDatumLink>\n root={graph}\n size={[innerWidth, innerHeight]}\n nodeWidth={nodeWidth}\n nodePadding={nodePadding}\n nodeAlign={sankeyJustify}\n nodeId={(node) => node.id}\n nodeSort={nodeSort}\n >\n {({ graph: laidOut, createPath }) => (\n <>\n <Group className=\"visx-sankey-links\">\n {laidOut.links.map((link, index) => {\n const sourceId =\n typeof link.source === 'object'\n ? (\n link.source as SankeyNode<\n SankeyDatumNode,\n SankeyDatumLink\n >\n ).id\n : String(link.source)\n const stroke =\n link.color ?? colorById.get(sourceId) ?? defaultColor\n\n return (\n <path\n key={`sankey-link-${index}`}\n d={createPath(link) ?? ''}\n fill=\"none\"\n stroke={stroke}\n strokeOpacity={0.32}\n strokeWidth={Math.max(1, link.width ?? 0)}\n />\n )\n })}\n </Group>\n\n <Group className=\"visx-sankey-nodes\">\n {laidOut.nodes.map((node, index) => {\n if (\n node.x0 === undefined ||\n node.x1 === undefined ||\n node.y0 === undefined ||\n node.y1 === undefined\n ) {\n return null\n }\n\n const w = node.x1 - node.x0\n const h = node.y1 - node.y0\n const fill = colorById.get(node.id) ?? defaultColor\n\n const labelX = node.x1 + LABEL_GAP\n // Center the name+value label on the node, then clamp it\n // vertically so a node flush against an edge (e.g. a tiny\n // \"grid\" node at the top) doesn't push its name past the\n // chart bounds and get clipped. The name's ascenders sit\n // above labelY; the value box sits below it.\n const labelY = Math.min(\n Math.max(\n node.y0 + h / 2 - 4,\n LABEL_NAME_HEIGHT - TOP_INSET,\n ),\n innerHeight + TOP_INSET - VALUE_HEIGHT,\n )\n const labelAnchor = 'start' as const\n const primaryDy = 0\n const valueWidth = Math.max(\n 40,\n labelPadding - LABEL_GAP - 4,\n )\n const valueHeight = VALUE_HEIGHT\n const valueY = labelY + LABEL_VALUE_GAP\n\n return (\n <Group key={`sankey-node-${index}`}>\n <rect\n x={node.x0}\n y={node.y0}\n width={w}\n height={Math.max(1, h)}\n fill={fill}\n rx={2}\n />\n <text\n x={labelX}\n y={labelY + primaryDy}\n textAnchor={labelAnchor}\n dominantBaseline=\"alphabetic\"\n fontSize={11}\n fontWeight={600}\n fill=\"var(--gray-a12)\"\n >\n {node.label}\n </text>\n <foreignObject\n x={labelX}\n y={valueY}\n width={valueWidth}\n height={valueHeight}\n style={{ overflow: 'visible' }}\n >\n <div\n style={{\n lineHeight: 1.2,\n overflow: 'visible',\n }}\n >\n <NumberFlow\n value={node.value ?? 0}\n {...(valueSuffix !== undefined && {\n suffix: valueSuffix,\n })}\n {...(valueFormat !== undefined && {\n format: valueFormat,\n })}\n className=\"text-[10px] text-gray-light tabular-nums\"\n />\n </div>\n </foreignObject>\n </Group>\n )\n })}\n </Group>\n </>\n )}\n </Sankey>\n </Group>\n </svg>\n )}\n </div>\n </div>\n )\n}\n\nexport type { SankeyLink, SankeyNode }\nexport { SankeyChart, SANKEY_CATEGORY_COLORS }\n"],"mappings":"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"}
1
+ {"version":3,"file":"sankey-chart.cjs","names":["SectionTitle","Group","Sankey","NumberFlow"],"sources":["../../src/components/sankey-chart.tsx"],"sourcesContent":["import { Group } from '@visx/group'\nimport {\n Sankey,\n sankeyJustify,\n type SankeyLink,\n type SankeyNode,\n} from '@visx/sankey'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '../utils/cn'\nimport NumberFlow, { type Format } from './number-flow'\nimport { SectionTitle } from './typography'\n\nconst SANKEY_CATEGORY_COLORS: Record<string, string> = {\n solar: 'var(--orange-a9)',\n battery: 'var(--green-a9)',\n grid: 'var(--blue-9)',\n gridFeedIn: 'var(--blue-7)',\n home: 'var(--spark-9)',\n floor: 'var(--gray-a9)',\n device: 'var(--gray-a8)',\n}\n\nexport interface SankeyChartNode {\n id: string\n label: string\n /** Optional category — used to colour the node and any outgoing links. */\n category?: string\n /** Override the colour resolved from `category`. */\n color?: string\n}\n\nexport interface SankeyChartLink {\n source: string\n target: string\n value: number\n /** Override the colour of the link. Defaults to the source node colour. */\n color?: string\n}\n\nexport interface SankeyChartProps {\n nodes: SankeyChartNode[]\n links: SankeyChartLink[]\n /** Resolves a category to a colour. */\n categoryColors?: Record<string, string>\n /** Fallback colour for nodes/links without a category. */\n defaultColor?: string\n /** Suffix appended to each animated value label (e.g. \" W\"). */\n valueSuffix?: string\n /** NumberFlow `format` for value labels. */\n valueFormat?: Format\n height?: number\n nodeWidth?: number\n nodePadding?: number\n /** Reserved horizontal space on the right for labels. */\n labelPadding?: number\n /**\n * Node ids kept at their natural (computed) column instead of being pushed to\n * the last column. By default `sankeyJustify` sends every node with no\n * outgoing link to the right edge; use this to hold a terminal node (e.g. grid\n * feed-in) in a middle column alongside its siblings.\n */\n keepAtDepthIds?: readonly string[]\n title?: string\n className?: string\n}\n\ntype SankeyDatumNode = SankeyChartNode & {\n /** Computed by d3-sankey; present after layout. */\n}\n\ntype SankeyDatumLink = SankeyChartLink\n\nconst DEFAULT_HEIGHT = 260\nconst DEFAULT_NODE_WIDTH = 12\nconst DEFAULT_NODE_PADDING = 10\nconst DEFAULT_LABEL_PADDING = 96\nconst LEFT_INSET = 4\nconst TOP_INSET = 8\nconst LABEL_GAP = 8\n/** Approx. cap/ascender height of the node name above its alphabetic baseline. */\nconst LABEL_NAME_HEIGHT = 11\n/** Vertical offset of the value box relative to the name baseline. Negative pulls\n * the value up toward the name to tighten the gap between them. */\nconst LABEL_VALUE_GAP = -4\n/** Height of the value box rendered below the node name. */\nconst VALUE_HEIGHT = 16\nconst DEFAULT_COLOR = 'var(--gray-a9)'\n\nfunction resolveNodeColor(\n node: SankeyChartNode,\n categoryColors: Record<string, string> | undefined,\n defaultColor: string,\n) {\n if (node.color) return node.color\n if (node.category && categoryColors?.[node.category]) {\n return categoryColors[node.category] as string\n }\n return defaultColor\n}\n\nfunction SankeyChart({\n nodes,\n links,\n categoryColors = SANKEY_CATEGORY_COLORS,\n defaultColor = DEFAULT_COLOR,\n valueSuffix,\n valueFormat,\n height = DEFAULT_HEIGHT,\n nodeWidth = DEFAULT_NODE_WIDTH,\n nodePadding = DEFAULT_NODE_PADDING,\n labelPadding = DEFAULT_LABEL_PADDING,\n keepAtDepthIds,\n title,\n className,\n}: SankeyChartProps): React.ReactElement {\n const containerRef = useRef<HTMLDivElement>(null)\n const [width, setWidth] = useState(0)\n\n useEffect(() => {\n const element = containerRef.current\n if (!element) return\n\n const resizeObserver = new ResizeObserver((entries) => {\n const entry = entries[0]\n if (!entry) return\n setWidth(entry.contentRect.width)\n })\n\n resizeObserver.observe(element)\n return () => resizeObserver.disconnect()\n }, [])\n\n const nodeIds = useMemo(() => new Set(nodes.map((node) => node.id)), [nodes])\n\n // Drop zero-value links and any whose endpoints aren't in the current node\n // set (a stale link referencing a removed node makes d3-sankey throw\n // `missing: <id>`).\n const visibleLinks = useMemo(\n () =>\n links.filter(\n (link) =>\n link.value > 0 &&\n nodeIds.has(link.source) &&\n nodeIds.has(link.target),\n ),\n [links, nodeIds],\n )\n\n const visibleNodes = useMemo(() => {\n const referencedIds = new Set<string>()\n for (const link of visibleLinks) {\n referencedIds.add(link.source)\n referencedIds.add(link.target)\n }\n return nodes.filter((node) => referencedIds.has(node.id))\n }, [nodes, visibleLinks])\n\n const colorById = useMemo(() => {\n const map = new Map<string, string>()\n for (const node of nodes) {\n map.set(node.id, resolveNodeColor(node, categoryColors, defaultColor))\n }\n return map\n }, [categoryColors, defaultColor, nodes])\n\n const orderById = useMemo(() => {\n const map = new Map<string, number>()\n visibleNodes.forEach((node, index) => map.set(node.id, index))\n return map\n }, [visibleNodes])\n\n const nodeSort = useMemo(\n () =>\n (\n a: SankeyNode<SankeyDatumNode, SankeyDatumLink>,\n b: SankeyNode<SankeyDatumNode, SankeyDatumLink>,\n ) =>\n (orderById.get(a.id) ?? 0) - (orderById.get(b.id) ?? 0),\n [orderById],\n )\n\n const graph = useMemo(\n () => ({\n nodes: visibleNodes.map((node) => ({ ...node })),\n links: visibleLinks.map((link) => ({ ...link })),\n }),\n [visibleNodes, visibleLinks],\n )\n\n const nodeAlign = useMemo(() => {\n const keep = new Set(keepAtDepthIds)\n return (node: SankeyNode<SankeyDatumNode, SankeyDatumLink>, n: number) =>\n keep.has(node.id) ? (node.depth ?? 0) : sankeyJustify(node, n)\n }, [keepAtDepthIds])\n\n const innerWidth = Math.max(0, width - labelPadding - LEFT_INSET)\n const innerHeight = Math.max(0, height - TOP_INSET * 2)\n\n return (\n <div className={cn('flex w-full flex-col gap-2 py-2', className)}>\n {title && <SectionTitle>{title}</SectionTitle>}\n\n <div ref={containerRef} className=\"w-full\" style={{ height }}>\n {width > 0 && innerWidth > 0 && (\n <svg width={width} height={height}>\n <Group left={LEFT_INSET} top={TOP_INSET}>\n <Sankey<SankeyDatumNode, SankeyDatumLink>\n root={graph}\n size={[innerWidth, innerHeight]}\n nodeWidth={nodeWidth}\n nodePadding={nodePadding}\n nodeAlign={nodeAlign}\n nodeId={(node) => node.id}\n nodeSort={nodeSort}\n >\n {({ graph: laidOut, createPath }) => (\n <>\n <Group className=\"visx-sankey-links\">\n {laidOut.links.map((link, index) => {\n const sourceId =\n typeof link.source === 'object'\n ? (\n link.source as SankeyNode<\n SankeyDatumNode,\n SankeyDatumLink\n >\n ).id\n : String(link.source)\n const stroke =\n link.color ?? colorById.get(sourceId) ?? defaultColor\n\n return (\n <path\n key={`sankey-link-${index}`}\n d={createPath(link) ?? ''}\n fill=\"none\"\n stroke={stroke}\n strokeOpacity={0.32}\n strokeWidth={Math.max(1, link.width ?? 0)}\n />\n )\n })}\n </Group>\n\n <Group className=\"visx-sankey-nodes\">\n {laidOut.nodes.map((node, index) => {\n if (\n node.x0 === undefined ||\n node.x1 === undefined ||\n node.y0 === undefined ||\n node.y1 === undefined\n ) {\n return null\n }\n\n const w = node.x1 - node.x0\n const h = node.y1 - node.y0\n const fill = colorById.get(node.id) ?? defaultColor\n\n const labelX = node.x1 + LABEL_GAP\n // Center the name+value label on the node, then clamp it\n // vertically so a node flush against an edge (e.g. a tiny\n // \"grid\" node at the top) doesn't push its name past the\n // chart bounds and get clipped. The name's ascenders sit\n // above labelY; the value box sits below it.\n const labelY = Math.min(\n Math.max(\n node.y0 + h / 2 - 4,\n LABEL_NAME_HEIGHT - TOP_INSET,\n ),\n innerHeight + TOP_INSET - VALUE_HEIGHT,\n )\n const labelAnchor = 'start' as const\n const primaryDy = 0\n const valueWidth = Math.max(\n 40,\n labelPadding - LABEL_GAP - 4,\n )\n const valueHeight = VALUE_HEIGHT\n const valueY = labelY + LABEL_VALUE_GAP\n\n return (\n <Group key={`sankey-node-${index}`}>\n <rect\n x={node.x0}\n y={node.y0}\n width={w}\n height={Math.max(1, h)}\n fill={fill}\n rx={2}\n />\n <text\n x={labelX}\n y={labelY + primaryDy}\n textAnchor={labelAnchor}\n dominantBaseline=\"alphabetic\"\n fontSize={11}\n fontWeight={600}\n fill=\"var(--gray-a12)\"\n >\n {node.label}\n </text>\n <foreignObject\n x={labelX}\n y={valueY}\n width={valueWidth}\n height={valueHeight}\n style={{ overflow: 'visible' }}\n >\n <div\n style={{\n lineHeight: 1.2,\n overflow: 'visible',\n }}\n >\n <NumberFlow\n value={node.value ?? 0}\n {...(valueSuffix !== undefined && {\n suffix: valueSuffix,\n })}\n {...(valueFormat !== undefined && {\n format: valueFormat,\n })}\n className=\"text-[10px] text-gray-light tabular-nums\"\n />\n </div>\n </foreignObject>\n </Group>\n )\n })}\n </Group>\n </>\n )}\n </Sankey>\n </Group>\n </svg>\n )}\n </div>\n </div>\n )\n}\n\nexport type { SankeyLink, SankeyNode }\nexport { SankeyChart, SANKEY_CATEGORY_COLORS }\n"],"mappings":"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"}
@@ -36,6 +36,13 @@ interface SankeyChartProps {
36
36
  nodePadding?: number;
37
37
  /** Reserved horizontal space on the right for labels. */
38
38
  labelPadding?: number;
39
+ /**
40
+ * Node ids kept at their natural (computed) column instead of being pushed to
41
+ * the last column. By default `sankeyJustify` sends every node with no
42
+ * outgoing link to the right edge; use this to hold a terminal node (e.g. grid
43
+ * feed-in) in a middle column alongside its siblings.
44
+ */
45
+ keepAtDepthIds?: readonly string[];
39
46
  title?: string;
40
47
  className?: string;
41
48
  }
@@ -50,6 +57,7 @@ declare function SankeyChart({
50
57
  nodeWidth,
51
58
  nodePadding,
52
59
  labelPadding,
60
+ keepAtDepthIds,
53
61
  title,
54
62
  className
55
63
  }: SankeyChartProps): React.ReactElement;
@@ -36,6 +36,13 @@ interface SankeyChartProps {
36
36
  nodePadding?: number;
37
37
  /** Reserved horizontal space on the right for labels. */
38
38
  labelPadding?: number;
39
+ /**
40
+ * Node ids kept at their natural (computed) column instead of being pushed to
41
+ * the last column. By default `sankeyJustify` sends every node with no
42
+ * outgoing link to the right edge; use this to hold a terminal node (e.g. grid
43
+ * feed-in) in a middle column alongside its siblings.
44
+ */
45
+ keepAtDepthIds?: readonly string[];
39
46
  title?: string;
40
47
  className?: string;
41
48
  }
@@ -50,6 +57,7 @@ declare function SankeyChart({
50
57
  nodeWidth,
51
58
  nodePadding,
52
59
  labelPadding,
60
+ keepAtDepthIds,
53
61
  title,
54
62
  className
55
63
  }: SankeyChartProps): React.ReactElement;
@@ -11,6 +11,7 @@ const SANKEY_CATEGORY_COLORS = {
11
11
  solar: "var(--orange-a9)",
12
12
  battery: "var(--green-a9)",
13
13
  grid: "var(--blue-9)",
14
+ gridFeedIn: "var(--blue-7)",
14
15
  home: "var(--spark-9)",
15
16
  floor: "var(--gray-a9)",
16
17
  device: "var(--gray-a8)"
@@ -30,49 +31,12 @@ const LABEL_VALUE_GAP = -4;
30
31
  /** Height of the value box rendered below the node name. */
31
32
  const VALUE_HEIGHT = 16;
32
33
  const DEFAULT_COLOR = "var(--gray-a9)";
33
- const ANIMATION_DURATION_MS = 500;
34
34
  function resolveNodeColor(node, categoryColors, defaultColor) {
35
35
  if (node.color) return node.color;
36
36
  if (node.category && categoryColors?.[node.category]) return categoryColors[node.category];
37
37
  return defaultColor;
38
38
  }
39
- function easeOutCubic(t) {
40
- return 1 - (1 - t) ** 3;
41
- }
42
- function linkKey(link) {
43
- return `${link.source}-${link.target}`;
44
- }
45
- /**
46
- * Interpolates link values toward `links` over ~500ms so visx re-layouts each
47
- * frame, smoothly tweening node rects and link paths.
48
- */
49
- function useAnimatedLinks(links) {
50
- const [animated, setAnimated] = useState(links);
51
- const animatedRef = useRef(links);
52
- useEffect(() => {
53
- const fromByKey = new Map(animatedRef.current.map((l) => [linkKey(l), l]));
54
- const start = performance.now();
55
- let raf = 0;
56
- const tick = (now) => {
57
- const t = Math.min(1, (now - start) / ANIMATION_DURATION_MS);
58
- const eased = easeOutCubic(t);
59
- const next = links.map((toLink) => {
60
- const fromValue = fromByKey.get(linkKey(toLink))?.value ?? toLink.value;
61
- return {
62
- ...toLink,
63
- value: Math.round(fromValue + (toLink.value - fromValue) * eased)
64
- };
65
- });
66
- animatedRef.current = next;
67
- setAnimated(next);
68
- if (t < 1) raf = requestAnimationFrame(tick);
69
- };
70
- raf = requestAnimationFrame(tick);
71
- return () => cancelAnimationFrame(raf);
72
- }, [links]);
73
- return animated;
74
- }
75
- function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, defaultColor = DEFAULT_COLOR, valueSuffix, valueFormat, height = DEFAULT_HEIGHT, nodeWidth = DEFAULT_NODE_WIDTH, nodePadding = DEFAULT_NODE_PADDING, labelPadding = DEFAULT_LABEL_PADDING, title, className }) {
39
+ function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, defaultColor = DEFAULT_COLOR, valueSuffix, valueFormat, height = DEFAULT_HEIGHT, nodeWidth = DEFAULT_NODE_WIDTH, nodePadding = DEFAULT_NODE_PADDING, labelPadding = DEFAULT_LABEL_PADDING, keepAtDepthIds, title, className }) {
76
40
  const containerRef = useRef(null);
77
41
  const [width, setWidth] = useState(0);
78
42
  useEffect(() => {
@@ -86,9 +50,8 @@ function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, de
86
50
  resizeObserver.observe(element);
87
51
  return () => resizeObserver.disconnect();
88
52
  }, []);
89
- const animatedLinks = useAnimatedLinks(links);
90
53
  const nodeIds = useMemo(() => new Set(nodes.map((node) => node.id)), [nodes]);
91
- const visibleLinks = useMemo(() => animatedLinks.filter((link) => link.value > 0 && nodeIds.has(link.source) && nodeIds.has(link.target)), [animatedLinks, nodeIds]);
54
+ const visibleLinks = useMemo(() => links.filter((link) => link.value > 0 && nodeIds.has(link.source) && nodeIds.has(link.target)), [links, nodeIds]);
92
55
  const visibleNodes = useMemo(() => {
93
56
  const referencedIds = /* @__PURE__ */ new Set();
94
57
  for (const link of visibleLinks) {
@@ -116,6 +79,10 @@ function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, de
116
79
  nodes: visibleNodes.map((node) => ({ ...node })),
117
80
  links: visibleLinks.map((link) => ({ ...link }))
118
81
  }), [visibleNodes, visibleLinks]);
82
+ const nodeAlign = useMemo(() => {
83
+ const keep = new Set(keepAtDepthIds);
84
+ return (node, n) => keep.has(node.id) ? node.depth ?? 0 : sankeyJustify(node, n);
85
+ }, [keepAtDepthIds]);
119
86
  const innerWidth = Math.max(0, width - labelPadding - LEFT_INSET);
120
87
  const innerHeight = Math.max(0, height - TOP_INSET * 2);
121
88
  return /* @__PURE__ */ jsxs("div", {
@@ -135,7 +102,7 @@ function SankeyChart({ nodes, links, categoryColors = SANKEY_CATEGORY_COLORS, de
135
102
  size: [innerWidth, innerHeight],
136
103
  nodeWidth,
137
104
  nodePadding,
138
- nodeAlign: sankeyJustify,
105
+ nodeAlign,
139
106
  nodeId: (node) => node.id,
140
107
  nodeSort,
141
108
  children: ({ graph: laidOut, createPath }) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Group, {
@@ -1 +1 @@
1
- {"version":3,"file":"sankey-chart.js","names":[],"sources":["../../src/components/sankey-chart.tsx"],"sourcesContent":["import { Group } from '@visx/group'\nimport {\n Sankey,\n sankeyJustify,\n type SankeyLink,\n type SankeyNode,\n} from '@visx/sankey'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '../utils/cn'\nimport NumberFlow, { type Format } from './number-flow'\nimport { SectionTitle } from './typography'\n\nconst SANKEY_CATEGORY_COLORS: Record<string, string> = {\n solar: 'var(--orange-a9)',\n battery: 'var(--green-a9)',\n grid: 'var(--blue-9)',\n home: 'var(--spark-9)',\n floor: 'var(--gray-a9)',\n device: 'var(--gray-a8)',\n}\n\nexport interface SankeyChartNode {\n id: string\n label: string\n /** Optional category — used to colour the node and any outgoing links. */\n category?: string\n /** Override the colour resolved from `category`. */\n color?: string\n}\n\nexport interface SankeyChartLink {\n source: string\n target: string\n value: number\n /** Override the colour of the link. Defaults to the source node colour. */\n color?: string\n}\n\nexport interface SankeyChartProps {\n nodes: SankeyChartNode[]\n links: SankeyChartLink[]\n /** Resolves a category to a colour. */\n categoryColors?: Record<string, string>\n /** Fallback colour for nodes/links without a category. */\n defaultColor?: string\n /** Suffix appended to each animated value label (e.g. \" W\"). */\n valueSuffix?: string\n /** NumberFlow `format` for value labels. */\n valueFormat?: Format\n height?: number\n nodeWidth?: number\n nodePadding?: number\n /** Reserved horizontal space on the right for labels. */\n labelPadding?: number\n title?: string\n className?: string\n}\n\ntype SankeyDatumNode = SankeyChartNode & {\n /** Computed by d3-sankey; present after layout. */\n}\n\ntype SankeyDatumLink = SankeyChartLink\n\nconst DEFAULT_HEIGHT = 260\nconst DEFAULT_NODE_WIDTH = 12\nconst DEFAULT_NODE_PADDING = 10\nconst DEFAULT_LABEL_PADDING = 96\nconst LEFT_INSET = 4\nconst TOP_INSET = 8\nconst LABEL_GAP = 8\n/** Approx. cap/ascender height of the node name above its alphabetic baseline. */\nconst LABEL_NAME_HEIGHT = 11\n/** Vertical offset of the value box relative to the name baseline. Negative pulls\n * the value up toward the name to tighten the gap between them. */\nconst LABEL_VALUE_GAP = -4\n/** Height of the value box rendered below the node name. */\nconst VALUE_HEIGHT = 16\nconst DEFAULT_COLOR = 'var(--gray-a9)'\nconst ANIMATION_DURATION_MS = 500\n\nfunction resolveNodeColor(\n node: SankeyChartNode,\n categoryColors: Record<string, string> | undefined,\n defaultColor: string,\n) {\n if (node.color) return node.color\n if (node.category && categoryColors?.[node.category]) {\n return categoryColors[node.category] as string\n }\n return defaultColor\n}\n\nfunction easeOutCubic(t: number) {\n return 1 - (1 - t) ** 3\n}\n\nfunction linkKey(link: SankeyChartLink) {\n return `${link.source}-${link.target}`\n}\n\n/**\n * Interpolates link values toward `links` over ~500ms so visx re-layouts each\n * frame, smoothly tweening node rects and link paths.\n */\nfunction useAnimatedLinks(links: SankeyChartLink[]): SankeyChartLink[] {\n const [animated, setAnimated] = useState<SankeyChartLink[]>(links)\n const animatedRef = useRef<SankeyChartLink[]>(links)\n\n useEffect(() => {\n const fromByKey = new Map(animatedRef.current.map((l) => [linkKey(l), l]))\n const start = performance.now()\n let raf = 0\n\n const tick = (now: number) => {\n const t = Math.min(1, (now - start) / ANIMATION_DURATION_MS)\n const eased = easeOutCubic(t)\n const next = links.map((toLink) => {\n const fromValue = fromByKey.get(linkKey(toLink))?.value ?? toLink.value\n return {\n ...toLink,\n value: Math.round(fromValue + (toLink.value - fromValue) * eased),\n }\n })\n animatedRef.current = next\n setAnimated(next)\n if (t < 1) raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n return () => cancelAnimationFrame(raf)\n }, [links])\n\n return animated\n}\n\nfunction SankeyChart({\n nodes,\n links,\n categoryColors = SANKEY_CATEGORY_COLORS,\n defaultColor = DEFAULT_COLOR,\n valueSuffix,\n valueFormat,\n height = DEFAULT_HEIGHT,\n nodeWidth = DEFAULT_NODE_WIDTH,\n nodePadding = DEFAULT_NODE_PADDING,\n labelPadding = DEFAULT_LABEL_PADDING,\n title,\n className,\n}: SankeyChartProps): React.ReactElement {\n const containerRef = useRef<HTMLDivElement>(null)\n const [width, setWidth] = useState(0)\n\n useEffect(() => {\n const element = containerRef.current\n if (!element) return\n\n const resizeObserver = new ResizeObserver((entries) => {\n const entry = entries[0]\n if (!entry) return\n setWidth(entry.contentRect.width)\n })\n\n resizeObserver.observe(element)\n return () => resizeObserver.disconnect()\n }, [])\n\n const animatedLinks = useAnimatedLinks(links)\n\n const nodeIds = useMemo(() => new Set(nodes.map((node) => node.id)), [nodes])\n\n // Drop links whose endpoints aren't in the current node set. `animatedLinks`\n // lags `nodes` by a frame while tweening, so during a node add/remove a link\n // can momentarily reference a node that no longer exists — which makes\n // d3-sankey throw `missing: <id>`.\n const visibleLinks = useMemo(\n () =>\n animatedLinks.filter(\n (link) =>\n link.value > 0 &&\n nodeIds.has(link.source) &&\n nodeIds.has(link.target),\n ),\n [animatedLinks, nodeIds],\n )\n\n const visibleNodes = useMemo(() => {\n const referencedIds = new Set<string>()\n for (const link of visibleLinks) {\n referencedIds.add(link.source)\n referencedIds.add(link.target)\n }\n return nodes.filter((node) => referencedIds.has(node.id))\n }, [nodes, visibleLinks])\n\n const colorById = useMemo(() => {\n const map = new Map<string, string>()\n for (const node of nodes) {\n map.set(node.id, resolveNodeColor(node, categoryColors, defaultColor))\n }\n return map\n }, [categoryColors, defaultColor, nodes])\n\n const orderById = useMemo(() => {\n const map = new Map<string, number>()\n visibleNodes.forEach((node, index) => map.set(node.id, index))\n return map\n }, [visibleNodes])\n\n const nodeSort = useMemo(\n () =>\n (\n a: SankeyNode<SankeyDatumNode, SankeyDatumLink>,\n b: SankeyNode<SankeyDatumNode, SankeyDatumLink>,\n ) =>\n (orderById.get(a.id) ?? 0) - (orderById.get(b.id) ?? 0),\n [orderById],\n )\n\n const graph = useMemo(\n () => ({\n nodes: visibleNodes.map((node) => ({ ...node })),\n links: visibleLinks.map((link) => ({ ...link })),\n }),\n [visibleNodes, visibleLinks],\n )\n\n const innerWidth = Math.max(0, width - labelPadding - LEFT_INSET)\n const innerHeight = Math.max(0, height - TOP_INSET * 2)\n\n return (\n <div className={cn('flex w-full flex-col gap-2 py-2', className)}>\n {title && <SectionTitle>{title}</SectionTitle>}\n\n <div ref={containerRef} className=\"w-full\" style={{ height }}>\n {width > 0 && innerWidth > 0 && (\n <svg width={width} height={height}>\n <Group left={LEFT_INSET} top={TOP_INSET}>\n <Sankey<SankeyDatumNode, SankeyDatumLink>\n root={graph}\n size={[innerWidth, innerHeight]}\n nodeWidth={nodeWidth}\n nodePadding={nodePadding}\n nodeAlign={sankeyJustify}\n nodeId={(node) => node.id}\n nodeSort={nodeSort}\n >\n {({ graph: laidOut, createPath }) => (\n <>\n <Group className=\"visx-sankey-links\">\n {laidOut.links.map((link, index) => {\n const sourceId =\n typeof link.source === 'object'\n ? (\n link.source as SankeyNode<\n SankeyDatumNode,\n SankeyDatumLink\n >\n ).id\n : String(link.source)\n const stroke =\n link.color ?? colorById.get(sourceId) ?? defaultColor\n\n return (\n <path\n key={`sankey-link-${index}`}\n d={createPath(link) ?? ''}\n fill=\"none\"\n stroke={stroke}\n strokeOpacity={0.32}\n strokeWidth={Math.max(1, link.width ?? 0)}\n />\n )\n })}\n </Group>\n\n <Group className=\"visx-sankey-nodes\">\n {laidOut.nodes.map((node, index) => {\n if (\n node.x0 === undefined ||\n node.x1 === undefined ||\n node.y0 === undefined ||\n node.y1 === undefined\n ) {\n return null\n }\n\n const w = node.x1 - node.x0\n const h = node.y1 - node.y0\n const fill = colorById.get(node.id) ?? defaultColor\n\n const labelX = node.x1 + LABEL_GAP\n // Center the name+value label on the node, then clamp it\n // vertically so a node flush against an edge (e.g. a tiny\n // \"grid\" node at the top) doesn't push its name past the\n // chart bounds and get clipped. The name's ascenders sit\n // above labelY; the value box sits below it.\n const labelY = Math.min(\n Math.max(\n node.y0 + h / 2 - 4,\n LABEL_NAME_HEIGHT - TOP_INSET,\n ),\n innerHeight + TOP_INSET - VALUE_HEIGHT,\n )\n const labelAnchor = 'start' as const\n const primaryDy = 0\n const valueWidth = Math.max(\n 40,\n labelPadding - LABEL_GAP - 4,\n )\n const valueHeight = VALUE_HEIGHT\n const valueY = labelY + LABEL_VALUE_GAP\n\n return (\n <Group key={`sankey-node-${index}`}>\n <rect\n x={node.x0}\n y={node.y0}\n width={w}\n height={Math.max(1, h)}\n fill={fill}\n rx={2}\n />\n <text\n x={labelX}\n y={labelY + primaryDy}\n textAnchor={labelAnchor}\n dominantBaseline=\"alphabetic\"\n fontSize={11}\n fontWeight={600}\n fill=\"var(--gray-a12)\"\n >\n {node.label}\n </text>\n <foreignObject\n x={labelX}\n y={valueY}\n width={valueWidth}\n height={valueHeight}\n style={{ overflow: 'visible' }}\n >\n <div\n style={{\n lineHeight: 1.2,\n overflow: 'visible',\n }}\n >\n <NumberFlow\n value={node.value ?? 0}\n {...(valueSuffix !== undefined && {\n suffix: valueSuffix,\n })}\n {...(valueFormat !== undefined && {\n format: valueFormat,\n })}\n className=\"text-[10px] text-gray-light tabular-nums\"\n />\n </div>\n </foreignObject>\n </Group>\n )\n })}\n </Group>\n </>\n )}\n </Sankey>\n </Group>\n </svg>\n )}\n </div>\n </div>\n )\n}\n\nexport type { SankeyLink, SankeyNode }\nexport { SankeyChart, SANKEY_CATEGORY_COLORS }\n"],"mappings":"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"}
1
+ {"version":3,"file":"sankey-chart.js","names":[],"sources":["../../src/components/sankey-chart.tsx"],"sourcesContent":["import { Group } from '@visx/group'\nimport {\n Sankey,\n sankeyJustify,\n type SankeyLink,\n type SankeyNode,\n} from '@visx/sankey'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '../utils/cn'\nimport NumberFlow, { type Format } from './number-flow'\nimport { SectionTitle } from './typography'\n\nconst SANKEY_CATEGORY_COLORS: Record<string, string> = {\n solar: 'var(--orange-a9)',\n battery: 'var(--green-a9)',\n grid: 'var(--blue-9)',\n gridFeedIn: 'var(--blue-7)',\n home: 'var(--spark-9)',\n floor: 'var(--gray-a9)',\n device: 'var(--gray-a8)',\n}\n\nexport interface SankeyChartNode {\n id: string\n label: string\n /** Optional category — used to colour the node and any outgoing links. */\n category?: string\n /** Override the colour resolved from `category`. */\n color?: string\n}\n\nexport interface SankeyChartLink {\n source: string\n target: string\n value: number\n /** Override the colour of the link. Defaults to the source node colour. */\n color?: string\n}\n\nexport interface SankeyChartProps {\n nodes: SankeyChartNode[]\n links: SankeyChartLink[]\n /** Resolves a category to a colour. */\n categoryColors?: Record<string, string>\n /** Fallback colour for nodes/links without a category. */\n defaultColor?: string\n /** Suffix appended to each animated value label (e.g. \" W\"). */\n valueSuffix?: string\n /** NumberFlow `format` for value labels. */\n valueFormat?: Format\n height?: number\n nodeWidth?: number\n nodePadding?: number\n /** Reserved horizontal space on the right for labels. */\n labelPadding?: number\n /**\n * Node ids kept at their natural (computed) column instead of being pushed to\n * the last column. By default `sankeyJustify` sends every node with no\n * outgoing link to the right edge; use this to hold a terminal node (e.g. grid\n * feed-in) in a middle column alongside its siblings.\n */\n keepAtDepthIds?: readonly string[]\n title?: string\n className?: string\n}\n\ntype SankeyDatumNode = SankeyChartNode & {\n /** Computed by d3-sankey; present after layout. */\n}\n\ntype SankeyDatumLink = SankeyChartLink\n\nconst DEFAULT_HEIGHT = 260\nconst DEFAULT_NODE_WIDTH = 12\nconst DEFAULT_NODE_PADDING = 10\nconst DEFAULT_LABEL_PADDING = 96\nconst LEFT_INSET = 4\nconst TOP_INSET = 8\nconst LABEL_GAP = 8\n/** Approx. cap/ascender height of the node name above its alphabetic baseline. */\nconst LABEL_NAME_HEIGHT = 11\n/** Vertical offset of the value box relative to the name baseline. Negative pulls\n * the value up toward the name to tighten the gap between them. */\nconst LABEL_VALUE_GAP = -4\n/** Height of the value box rendered below the node name. */\nconst VALUE_HEIGHT = 16\nconst DEFAULT_COLOR = 'var(--gray-a9)'\n\nfunction resolveNodeColor(\n node: SankeyChartNode,\n categoryColors: Record<string, string> | undefined,\n defaultColor: string,\n) {\n if (node.color) return node.color\n if (node.category && categoryColors?.[node.category]) {\n return categoryColors[node.category] as string\n }\n return defaultColor\n}\n\nfunction SankeyChart({\n nodes,\n links,\n categoryColors = SANKEY_CATEGORY_COLORS,\n defaultColor = DEFAULT_COLOR,\n valueSuffix,\n valueFormat,\n height = DEFAULT_HEIGHT,\n nodeWidth = DEFAULT_NODE_WIDTH,\n nodePadding = DEFAULT_NODE_PADDING,\n labelPadding = DEFAULT_LABEL_PADDING,\n keepAtDepthIds,\n title,\n className,\n}: SankeyChartProps): React.ReactElement {\n const containerRef = useRef<HTMLDivElement>(null)\n const [width, setWidth] = useState(0)\n\n useEffect(() => {\n const element = containerRef.current\n if (!element) return\n\n const resizeObserver = new ResizeObserver((entries) => {\n const entry = entries[0]\n if (!entry) return\n setWidth(entry.contentRect.width)\n })\n\n resizeObserver.observe(element)\n return () => resizeObserver.disconnect()\n }, [])\n\n const nodeIds = useMemo(() => new Set(nodes.map((node) => node.id)), [nodes])\n\n // Drop zero-value links and any whose endpoints aren't in the current node\n // set (a stale link referencing a removed node makes d3-sankey throw\n // `missing: <id>`).\n const visibleLinks = useMemo(\n () =>\n links.filter(\n (link) =>\n link.value > 0 &&\n nodeIds.has(link.source) &&\n nodeIds.has(link.target),\n ),\n [links, nodeIds],\n )\n\n const visibleNodes = useMemo(() => {\n const referencedIds = new Set<string>()\n for (const link of visibleLinks) {\n referencedIds.add(link.source)\n referencedIds.add(link.target)\n }\n return nodes.filter((node) => referencedIds.has(node.id))\n }, [nodes, visibleLinks])\n\n const colorById = useMemo(() => {\n const map = new Map<string, string>()\n for (const node of nodes) {\n map.set(node.id, resolveNodeColor(node, categoryColors, defaultColor))\n }\n return map\n }, [categoryColors, defaultColor, nodes])\n\n const orderById = useMemo(() => {\n const map = new Map<string, number>()\n visibleNodes.forEach((node, index) => map.set(node.id, index))\n return map\n }, [visibleNodes])\n\n const nodeSort = useMemo(\n () =>\n (\n a: SankeyNode<SankeyDatumNode, SankeyDatumLink>,\n b: SankeyNode<SankeyDatumNode, SankeyDatumLink>,\n ) =>\n (orderById.get(a.id) ?? 0) - (orderById.get(b.id) ?? 0),\n [orderById],\n )\n\n const graph = useMemo(\n () => ({\n nodes: visibleNodes.map((node) => ({ ...node })),\n links: visibleLinks.map((link) => ({ ...link })),\n }),\n [visibleNodes, visibleLinks],\n )\n\n const nodeAlign = useMemo(() => {\n const keep = new Set(keepAtDepthIds)\n return (node: SankeyNode<SankeyDatumNode, SankeyDatumLink>, n: number) =>\n keep.has(node.id) ? (node.depth ?? 0) : sankeyJustify(node, n)\n }, [keepAtDepthIds])\n\n const innerWidth = Math.max(0, width - labelPadding - LEFT_INSET)\n const innerHeight = Math.max(0, height - TOP_INSET * 2)\n\n return (\n <div className={cn('flex w-full flex-col gap-2 py-2', className)}>\n {title && <SectionTitle>{title}</SectionTitle>}\n\n <div ref={containerRef} className=\"w-full\" style={{ height }}>\n {width > 0 && innerWidth > 0 && (\n <svg width={width} height={height}>\n <Group left={LEFT_INSET} top={TOP_INSET}>\n <Sankey<SankeyDatumNode, SankeyDatumLink>\n root={graph}\n size={[innerWidth, innerHeight]}\n nodeWidth={nodeWidth}\n nodePadding={nodePadding}\n nodeAlign={nodeAlign}\n nodeId={(node) => node.id}\n nodeSort={nodeSort}\n >\n {({ graph: laidOut, createPath }) => (\n <>\n <Group className=\"visx-sankey-links\">\n {laidOut.links.map((link, index) => {\n const sourceId =\n typeof link.source === 'object'\n ? (\n link.source as SankeyNode<\n SankeyDatumNode,\n SankeyDatumLink\n >\n ).id\n : String(link.source)\n const stroke =\n link.color ?? colorById.get(sourceId) ?? defaultColor\n\n return (\n <path\n key={`sankey-link-${index}`}\n d={createPath(link) ?? ''}\n fill=\"none\"\n stroke={stroke}\n strokeOpacity={0.32}\n strokeWidth={Math.max(1, link.width ?? 0)}\n />\n )\n })}\n </Group>\n\n <Group className=\"visx-sankey-nodes\">\n {laidOut.nodes.map((node, index) => {\n if (\n node.x0 === undefined ||\n node.x1 === undefined ||\n node.y0 === undefined ||\n node.y1 === undefined\n ) {\n return null\n }\n\n const w = node.x1 - node.x0\n const h = node.y1 - node.y0\n const fill = colorById.get(node.id) ?? defaultColor\n\n const labelX = node.x1 + LABEL_GAP\n // Center the name+value label on the node, then clamp it\n // vertically so a node flush against an edge (e.g. a tiny\n // \"grid\" node at the top) doesn't push its name past the\n // chart bounds and get clipped. The name's ascenders sit\n // above labelY; the value box sits below it.\n const labelY = Math.min(\n Math.max(\n node.y0 + h / 2 - 4,\n LABEL_NAME_HEIGHT - TOP_INSET,\n ),\n innerHeight + TOP_INSET - VALUE_HEIGHT,\n )\n const labelAnchor = 'start' as const\n const primaryDy = 0\n const valueWidth = Math.max(\n 40,\n labelPadding - LABEL_GAP - 4,\n )\n const valueHeight = VALUE_HEIGHT\n const valueY = labelY + LABEL_VALUE_GAP\n\n return (\n <Group key={`sankey-node-${index}`}>\n <rect\n x={node.x0}\n y={node.y0}\n width={w}\n height={Math.max(1, h)}\n fill={fill}\n rx={2}\n />\n <text\n x={labelX}\n y={labelY + primaryDy}\n textAnchor={labelAnchor}\n dominantBaseline=\"alphabetic\"\n fontSize={11}\n fontWeight={600}\n fill=\"var(--gray-a12)\"\n >\n {node.label}\n </text>\n <foreignObject\n x={labelX}\n y={valueY}\n width={valueWidth}\n height={valueHeight}\n style={{ overflow: 'visible' }}\n >\n <div\n style={{\n lineHeight: 1.2,\n overflow: 'visible',\n }}\n >\n <NumberFlow\n value={node.value ?? 0}\n {...(valueSuffix !== undefined && {\n suffix: valueSuffix,\n })}\n {...(valueFormat !== undefined && {\n format: valueFormat,\n })}\n className=\"text-[10px] text-gray-light tabular-nums\"\n />\n </div>\n </foreignObject>\n </Group>\n )\n })}\n </Group>\n </>\n )}\n </Sankey>\n </Group>\n </svg>\n )}\n </div>\n </div>\n )\n}\n\nexport type { SankeyLink, SankeyNode }\nexport { SankeyChart, SANKEY_CATEGORY_COLORS }\n"],"mappings":"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"}