@cfasim-ui/charts 0.7.8 → 0.8.1

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 (67) hide show
  1. package/dist/BarChart/BarChart.d.ts +9 -26
  2. package/dist/ChartMenu/ChartMenu.d.ts +3 -2
  3. package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
  4. package/dist/ChoroplethMap/ChoroplethMap.d.ts +42 -190
  5. package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
  6. package/dist/ChoroplethMap/canvasLayer.d.ts +23 -6
  7. package/dist/ChoroplethMap/cityLayout.d.ts +3 -2
  8. package/dist/ChoroplethMap/mixedGeo.d.ts +74 -0
  9. package/dist/ChoroplethMap/mixedGeo.test.d.ts +1 -0
  10. package/dist/DataTable/DataTable.d.ts +3 -2
  11. package/dist/LineChart/LineChart.d.ts +9 -26
  12. package/dist/_shared/ChartAnnotations.d.ts +3 -2
  13. package/dist/_shared/ChartZoomControls.d.ts +3 -2
  14. package/dist/_shared/index.d.ts +2 -1
  15. package/dist/_shared/mapTheme.d.ts +159 -0
  16. package/dist/_shared/mapTheme.test.d.ts +1 -0
  17. package/dist/index.css +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1821 -1462
  20. package/docs/BarChart.md +776 -0
  21. package/docs/ChoroplethMap.md +1394 -0
  22. package/docs/DataTable.md +386 -0
  23. package/docs/LineChart.md +1267 -0
  24. package/docs/index.json +56 -0
  25. package/package.json +25 -21
  26. package/src/BarChart/BarChart.md +743 -0
  27. package/src/BarChart/BarChart.vue +1901 -0
  28. package/src/ChartMenu/ChartMenu.vue +220 -0
  29. package/src/ChartMenu/download.ts +120 -0
  30. package/src/ChartTooltip/ChartTooltip.vue +97 -0
  31. package/src/ChoroplethMap/ChoroplethMap.md +1354 -0
  32. package/src/ChoroplethMap/ChoroplethMap.vue +3778 -0
  33. package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
  34. package/src/ChoroplethMap/canvasLayer.ts +373 -0
  35. package/src/ChoroplethMap/cityLayout.ts +262 -0
  36. package/src/ChoroplethMap/hsaMapping.ts +4116 -0
  37. package/src/ChoroplethMap/mixedGeo.ts +201 -0
  38. package/src/DataTable/DataTable.md +372 -0
  39. package/src/DataTable/DataTable.vue +406 -0
  40. package/src/LineChart/LineChart.md +1225 -0
  41. package/src/LineChart/LineChart.vue +1555 -0
  42. package/src/_shared/ChartAnnotations.vue +420 -0
  43. package/src/_shared/ChartZoomControls.vue +138 -0
  44. package/src/_shared/annotations.ts +106 -0
  45. package/src/_shared/axes.ts +69 -0
  46. package/src/_shared/chartProps.ts +201 -0
  47. package/src/_shared/computeTicks.ts +42 -0
  48. package/src/_shared/contrast.ts +100 -0
  49. package/src/_shared/dateAxis.ts +501 -0
  50. package/src/_shared/index.ts +78 -0
  51. package/src/_shared/mapTheme.ts +551 -0
  52. package/src/_shared/scale.ts +86 -0
  53. package/src/_shared/seriesCsv.ts +68 -0
  54. package/src/_shared/touch.ts +8 -0
  55. package/src/_shared/useChartFoundation.ts +175 -0
  56. package/src/_shared/useChartFullscreen.ts +254 -0
  57. package/src/_shared/useChartMenu.ts +111 -0
  58. package/src/_shared/useChartPadding.ts +235 -0
  59. package/src/_shared/useChartSize.ts +58 -0
  60. package/src/_shared/useChartTooltip.ts +205 -0
  61. package/src/env.d.ts +4 -0
  62. package/src/hsa-mapping.ts +1 -0
  63. package/src/index.ts +41 -0
  64. package/src/tooltip-position.ts +55 -0
  65. package/src/us-cities/data.ts +1371 -0
  66. package/src/us-cities/index.ts +122 -0
  67. package/src/us-cities.ts +7 -0
@@ -0,0 +1,103 @@
1
+ <script setup lang="ts">
2
+ import { ref, useTemplateRef } from "vue";
3
+
4
+ export interface ChoroplethTooltipData {
5
+ id: string;
6
+ name: string;
7
+ value?: number | string;
8
+ feature: unknown;
9
+ }
10
+
11
+ withDefaults(
12
+ defineProps<{
13
+ /**
14
+ * `"float"` (default) renders the classic pointer-anchored tooltip.
15
+ * `"sheet"` renders a bottom sheet that slides up inside the map
16
+ * wrapper instead — used in the expanded touch view, where it stays
17
+ * correctly positioned regardless of the page's pinch-zoom state
18
+ * (the wrapper is pinned to the visual viewport; floating fixed
19
+ * positioning is not).
20
+ */
21
+ mode?: "float" | "sheet";
22
+ }>(),
23
+ { mode: "float" },
24
+ );
25
+
26
+ // Local reactive state. Held inside the child so the parent's render scope
27
+ // never subscribes to it — hover updates re-render only this small tree,
28
+ // not the parent's 3,000+ paths.
29
+ const data = ref<ChoroplethTooltipData | null>(null);
30
+ const open = ref(false);
31
+ const rootRef = useTemplateRef<HTMLDivElement>("root");
32
+
33
+ defineExpose({
34
+ setData(next: ChoroplethTooltipData | null) {
35
+ data.value = next;
36
+ },
37
+ /** Sheet-mode visibility (float mode is driven imperatively via getEl). */
38
+ setOpen(next: boolean) {
39
+ open.value = next;
40
+ },
41
+ getEl(): HTMLDivElement | null {
42
+ return rootRef.value;
43
+ },
44
+ });
45
+ </script>
46
+
47
+ <template>
48
+ <!-- Float: always mounted (the parent's ResizeObserver holds onto it);
49
+ its slot only renders in float mode. z-index sits one above the
50
+ fullscreen overlay so it stays visible while the map fills the window
51
+ (both live in body). -->
52
+ <Teleport to="body">
53
+ <div
54
+ ref="root"
55
+ class="chart-tooltip-content"
56
+ style="
57
+ position: fixed;
58
+ left: 0;
59
+ top: 0;
60
+ z-index: calc(var(--cfasim-z-fullscreen, 1000) + 1);
61
+ visibility: hidden;
62
+ will-change: transform;
63
+ pointer-events: none;
64
+ transform: translateY(-50%);
65
+ "
66
+ >
67
+ <slot v-if="data && mode === 'float'" v-bind="data" />
68
+ </div>
69
+ </Teleport>
70
+ <!-- Sheet: rendered in place inside the map wrapper, so it anchors to
71
+ the (visual-viewport-pinned) expanded box. -->
72
+ <div
73
+ v-if="mode === 'sheet'"
74
+ class="chart-tooltip-sheet"
75
+ :class="{ 'is-open': open }"
76
+ >
77
+ <slot v-if="data" v-bind="data" />
78
+ </div>
79
+ </template>
80
+
81
+ <style scoped>
82
+ .chart-tooltip-sheet {
83
+ position: absolute;
84
+ left: 0;
85
+ right: 0;
86
+ bottom: 0;
87
+ z-index: 2;
88
+ background: var(--color-bg-0, #fff);
89
+ color: var(--color-text, inherit);
90
+ border-top: 1px solid var(--color-border, #e5e7eb);
91
+ box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.12);
92
+ padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px));
93
+ font-size: 14px;
94
+ line-height: 1.4;
95
+ pointer-events: none;
96
+ transform: translateY(100%);
97
+ transition: transform 0.25s ease;
98
+ }
99
+
100
+ .chart-tooltip-sheet.is-open {
101
+ transform: translateY(0);
102
+ }
103
+ </style>
@@ -0,0 +1,373 @@
1
+ // Canvas rendering backend for ChoroplethMap (`renderer="canvas"`).
2
+ //
3
+ // Pure scene/draw/pick helpers, kept free of Vue and DOM lookups so they
4
+ // can be unit-tested against a recording fake context. The component owns
5
+ // all state; these functions just paint and answer point queries.
6
+
7
+ /** Matches ChoroplethMap's FocusStyle without importing from the SFC. */
8
+ export type CanvasDashStyle = "solid" | "dashed" | "dotted";
9
+
10
+ export interface SceneItem {
11
+ id: string;
12
+ path: Path2D;
13
+ fill: string;
14
+ }
15
+
16
+ export interface CanvasScene {
17
+ items: SceneItem[];
18
+ indexById: Map<string, number>;
19
+ /**
20
+ * Every feature stroke concatenated into one path — features share a
21
+ * stroke color, and one native stroke call beats thousands (WebKit's
22
+ * canvas2D especially).
23
+ */
24
+ featureStrokes: Path2D | null;
25
+ /** State-borders mesh (counties / hsas mode). */
26
+ borders: Path2D | null;
27
+ /**
28
+ * Exterior boundary of the rendered geography (theme.outline). Built
29
+ * lazily by the component when the outline resolves visible, so it is
30
+ * mutable scene state rather than a buildScene input.
31
+ */
32
+ exterior: Path2D | null;
33
+ }
34
+
35
+ export interface CanvasView {
36
+ dpr: number;
37
+ /** Uniform viewBox→CSS scale (xMidYMid meet). */
38
+ meetScale: number;
39
+ /** CSS-px letterbox offsets inside the canvas box. */
40
+ offsetX: number;
41
+ offsetY: number;
42
+ zoom: { k: number; x: number; y: number };
43
+ }
44
+
45
+ export interface CanvasOverlayItem {
46
+ path: Path2D;
47
+ stroke: string;
48
+ strokeWidth?: number;
49
+ style?: CanvasDashStyle;
50
+ }
51
+
52
+ export interface CanvasHighlightItem {
53
+ stroke?: string;
54
+ strokeWidth?: number;
55
+ style?: CanvasDashStyle;
56
+ }
57
+
58
+ export interface CanvasBaseState {
59
+ strokeColor: string;
60
+ /** Base feature stroke width in CSS px (effectiveStrokeWidth). */
61
+ strokeWidth: number;
62
+ /** State-borders mesh color (resolved theme.borders). */
63
+ bordersColor: string;
64
+ /** State-borders mesh width in CSS px; 0 disables. */
65
+ bordersWidth: number;
66
+ /** Exterior outline color; undefined = off (resolved theme.outline). */
67
+ outlineColor: string | undefined;
68
+ /** Exterior outline width in CSS px; 0 disables. */
69
+ outlineWidth: number;
70
+ /** Background wash painted before fills; undefined = off. */
71
+ background: string | undefined;
72
+ /** Resolved highlight color (light-dark() can't paint on canvas). */
73
+ highlightStroke: string;
74
+ focused: Map<string, CanvasHighlightItem>;
75
+ overlays: CanvasOverlayItem[];
76
+ }
77
+
78
+ export interface CanvasDrawState extends CanvasBaseState {
79
+ hoveredId: string | null;
80
+ }
81
+
82
+ function applyViewTransform(
83
+ ctx: CanvasRenderingContext2D,
84
+ view: CanvasView,
85
+ ): void {
86
+ const s = view.dpr * view.meetScale * view.zoom.k;
87
+ ctx.setTransform(
88
+ s,
89
+ 0,
90
+ 0,
91
+ s,
92
+ view.dpr * (view.offsetX + view.meetScale * view.zoom.x),
93
+ view.dpr * (view.offsetY + view.meetScale * view.zoom.y),
94
+ );
95
+ }
96
+
97
+ /** Line width (map units) rendering as `css` CSS px on screen. */
98
+ function lwFor(view: CanvasView): (css: number) => number {
99
+ return (css) => css / (view.meetScale * view.zoom.k || 1);
100
+ }
101
+
102
+ function applyLineDash(
103
+ ctx: CanvasRenderingContext2D,
104
+ lw: (css: number) => number,
105
+ style: CanvasDashStyle | undefined,
106
+ ): void {
107
+ if (style === "dashed") {
108
+ ctx.setLineDash([lw(8), lw(4)]);
109
+ ctx.lineCap = "butt";
110
+ } else if (style === "dotted") {
111
+ ctx.setLineDash([0, lw(5)]);
112
+ ctx.lineCap = "round";
113
+ } else {
114
+ ctx.setLineDash([]);
115
+ ctx.lineCap = "butt";
116
+ }
117
+ }
118
+
119
+ export function buildScene(
120
+ features: Array<{ id?: string | number | null }>,
121
+ pathFor: (feature: never) => string | null,
122
+ colorFor: (id: string) => string,
123
+ bordersD?: string | null,
124
+ ): CanvasScene {
125
+ const items: SceneItem[] = [];
126
+ const indexById = new Map<string, number>();
127
+ const featureStrokes = new Path2D();
128
+ let strokeCount = 0;
129
+ for (const feat of features) {
130
+ const d = pathFor(feat as never);
131
+ if (!d) continue;
132
+ const id = String(feat.id);
133
+ const path = new Path2D(d);
134
+ indexById.set(id, items.length);
135
+ items.push({ id, path, fill: colorFor(id) });
136
+ if (typeof featureStrokes.addPath === "function") {
137
+ featureStrokes.addPath(path);
138
+ strokeCount++;
139
+ }
140
+ }
141
+ return {
142
+ items,
143
+ indexById,
144
+ featureStrokes: strokeCount ? featureStrokes : null,
145
+ borders: bordersD ? new Path2D(bordersD) : null,
146
+ exterior: null,
147
+ };
148
+ }
149
+
150
+ function highlightOne(
151
+ ctx: CanvasRenderingContext2D,
152
+ scene: CanvasScene,
153
+ lw: (css: number) => number,
154
+ state: CanvasBaseState,
155
+ id: string,
156
+ item?: CanvasHighlightItem,
157
+ ): void {
158
+ const idx = scene.indexById.get(id);
159
+ if (idx == null) return;
160
+ const it = scene.items[idx];
161
+ // Re-filling first covers neighbors' strokes — the canvas equivalent of
162
+ // the SVG renderer's appendChild raise.
163
+ ctx.fillStyle = it.fill;
164
+ ctx.fill(it.path);
165
+ ctx.strokeStyle = item?.stroke ?? state.highlightStroke;
166
+ ctx.lineWidth = lw(item?.strokeWidth ?? state.strokeWidth + 1);
167
+ applyLineDash(ctx, lw, item?.style);
168
+ ctx.stroke(it.path);
169
+ }
170
+
171
+ /**
172
+ * Base pass internals composed by drawScene. The transform maps canonical
173
+ * viewBox coordinates to device pixels:
174
+ * `device = dpr · (letterboxOffset + meetScale · zoom(point))`. Stroke
175
+ * widths are compensated so a visual width `w` CSS px stays constant at
176
+ * any zoom, mirroring the SVG renderer's `strokeDivisor`.
177
+ */
178
+ function beginBasePass(
179
+ ctx: CanvasRenderingContext2D,
180
+ view: CanvasView,
181
+ background?: string,
182
+ ): void {
183
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
184
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
185
+ if (background) {
186
+ ctx.fillStyle = background;
187
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
188
+ }
189
+ applyViewTransform(ctx, view);
190
+ ctx.lineJoin = "round";
191
+ ctx.setLineDash([]);
192
+ }
193
+
194
+ /** Fills items [from, to); returns the next index. The ctx keeps the
195
+ * transform beginBasePass applied, so slices can span multiple frames. */
196
+ function drawFillSlice(
197
+ ctx: CanvasRenderingContext2D,
198
+ scene: CanvasScene,
199
+ from: number,
200
+ to: number,
201
+ ): number {
202
+ const end = Math.min(to, scene.items.length);
203
+ for (let i = from; i < end; i++) {
204
+ ctx.fillStyle = scene.items[i].fill;
205
+ ctx.fill(scene.items[i].path);
206
+ }
207
+ return end;
208
+ }
209
+
210
+ /** Feature strokes, borders, the exterior outline, focus highlights, and
211
+ * overlays — everything but the transient hover, which the display frame
212
+ * draws live on top of blits. */
213
+ function finishBasePass(
214
+ ctx: CanvasRenderingContext2D,
215
+ scene: CanvasScene,
216
+ view: CanvasView,
217
+ state: CanvasBaseState,
218
+ ): void {
219
+ const lw = lwFor(view);
220
+ ctx.setLineDash([]);
221
+ // A zero width disables a stroke layer entirely: canvas ignores
222
+ // `lineWidth = 0` (keeping the previous width), so skip explicitly.
223
+ if (state.strokeWidth > 0) {
224
+ ctx.lineWidth = lw(state.strokeWidth);
225
+ ctx.strokeStyle = state.strokeColor;
226
+ if (scene.featureStrokes) {
227
+ ctx.stroke(scene.featureStrokes);
228
+ } else {
229
+ for (const item of scene.items) ctx.stroke(item.path);
230
+ }
231
+ }
232
+
233
+ if (scene.borders && state.bordersWidth > 0) {
234
+ ctx.lineWidth = lw(state.bordersWidth);
235
+ ctx.strokeStyle = state.bordersColor;
236
+ ctx.stroke(scene.borders);
237
+ }
238
+
239
+ // Exterior outline paints above interior borders, below highlights.
240
+ if (scene.exterior && state.outlineColor && state.outlineWidth > 0) {
241
+ ctx.lineWidth = lw(state.outlineWidth);
242
+ ctx.strokeStyle = state.outlineColor;
243
+ ctx.stroke(scene.exterior);
244
+ }
245
+
246
+ for (const [id, item] of state.focused) {
247
+ highlightOne(ctx, scene, lw, state, id, item);
248
+ }
249
+
250
+ // Overlays above the focus highlights, matching the SVG z-order.
251
+ for (const o of state.overlays) {
252
+ ctx.strokeStyle = o.stroke;
253
+ ctx.lineWidth = lw(o.strokeWidth ?? state.strokeWidth + 1.5);
254
+ applyLineDash(ctx, lw, o.style);
255
+ ctx.stroke(o.path);
256
+ }
257
+ ctx.setLineDash([]);
258
+ }
259
+
260
+ /** Live hover highlight, drawn by the display frame on top of the blitted
261
+ * base (focused features are already highlighted in the base itself). */
262
+ export function drawHoverHighlight(
263
+ ctx: CanvasRenderingContext2D,
264
+ scene: CanvasScene,
265
+ view: CanvasView,
266
+ state: CanvasBaseState,
267
+ hoveredId: string | null,
268
+ ): void {
269
+ if (!hoveredId || state.focused.has(hoveredId)) return;
270
+ applyViewTransform(ctx, view);
271
+ ctx.lineJoin = "round";
272
+ highlightOne(ctx, scene, lwFor(view), state, hoveredId);
273
+ ctx.setLineDash([]);
274
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
275
+ }
276
+
277
+ /**
278
+ * Everything but the transient hover: clear, background, fills, feature
279
+ * strokes, borders, exterior outline, focus highlights, overlays. Split out
280
+ * so the component can cache this to an offscreen canvas and, on a
281
+ * hover-only change, blit it back + redraw the one highlight instead of
282
+ * re-filling every feature.
283
+ */
284
+ export function drawBase(
285
+ ctx: CanvasRenderingContext2D,
286
+ scene: CanvasScene,
287
+ view: CanvasView,
288
+ state: CanvasDrawState,
289
+ ): void {
290
+ beginBasePass(ctx, view, state.background);
291
+ drawFillSlice(ctx, scene, 0, scene.items.length);
292
+ finishBasePass(ctx, scene, view, state);
293
+ }
294
+
295
+ /** One-shot full repaint: the base pass plus the hover highlight on top. */
296
+ export function drawScene(
297
+ ctx: CanvasRenderingContext2D,
298
+ scene: CanvasScene,
299
+ view: CanvasView,
300
+ state: CanvasDrawState,
301
+ ): void {
302
+ drawBase(ctx, scene, view, state);
303
+ drawHoverHighlight(ctx, scene, view, state, state.hoveredId);
304
+ }
305
+
306
+ /** Unique fill color for feature index `i` on the picking canvas. */
307
+ export function indexToColor(i: number): string {
308
+ const v = i + 1;
309
+ return `rgb(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255})`;
310
+ }
311
+
312
+ /**
313
+ * Paints every feature in a unique index color onto `canvas` at canonical
314
+ * viewBox resolution. Rebuilt only when the geometry changes; hit-testing
315
+ * then reads one pixel per query regardless of feature count.
316
+ */
317
+ export function buildPicking(
318
+ scene: CanvasScene,
319
+ width: number,
320
+ height: number,
321
+ canvas: HTMLCanvasElement,
322
+ ): CanvasRenderingContext2D | null {
323
+ canvas.width = Math.max(1, Math.round(width));
324
+ canvas.height = Math.max(1, Math.round(height));
325
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
326
+ if (!ctx) return null;
327
+ ctx.imageSmoothingEnabled = false;
328
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
329
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
330
+ scene.items.forEach((item, i) => {
331
+ ctx.fillStyle = indexToColor(i);
332
+ ctx.fill(item.path);
333
+ });
334
+ return ctx;
335
+ }
336
+
337
+ /**
338
+ * Feature index at canonical viewBox coordinates (the caller has already
339
+ * inverted the zoom transform), or null over the background. Fill edges
340
+ * antialias into blended colors, so a candidate whose decoded index is
341
+ * implausible — or that fails a point-in-path check — falls back to a
342
+ * linear `isPointInPath` scan (rare, borders only).
343
+ */
344
+ export function pickIndexAt(
345
+ ctx: CanvasRenderingContext2D,
346
+ scene: CanvasScene,
347
+ x: number,
348
+ y: number,
349
+ ): number | null {
350
+ const px = Math.round(x);
351
+ const py = Math.round(y);
352
+ if (px < 0 || py < 0 || px >= ctx.canvas.width || py >= ctx.canvas.height) {
353
+ return null;
354
+ }
355
+ const d = ctx.getImageData(px, py, 1, 1).data;
356
+ if (d[3] === 0) return null;
357
+ const idx = ((d[0] << 16) | (d[1] << 8) | d[2]) - 1;
358
+ if (idx >= 0 && idx < scene.items.length) {
359
+ if (
360
+ typeof ctx.isPointInPath !== "function" ||
361
+ ctx.isPointInPath(scene.items[idx].path, x, y)
362
+ ) {
363
+ return idx;
364
+ }
365
+ }
366
+ // Antialiased edge pixel: verify the hard way.
367
+ if (typeof ctx.isPointInPath === "function") {
368
+ for (let i = 0; i < scene.items.length; i++) {
369
+ if (ctx.isPointInPath(scene.items[i].path, x, y)) return i;
370
+ }
371
+ }
372
+ return null;
373
+ }