@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,235 @@
1
+ import { computed } from "vue";
2
+ import type { LabelStyle, TitleStyle } from "./chartProps.js";
3
+
4
+ /** Vertical space reserved per row of the inline legend strip. */
5
+ export const INLINE_LEGEND_ROW_HEIGHT = 20;
6
+ /** Default line height (px) for the chart title; overridable per chart. */
7
+ export const TITLE_LINE_HEIGHT = 18;
8
+ /** Default font size (px) for the chart title; overridable per chart. */
9
+ export const TITLE_FONT_SIZE = 14;
10
+ /** Default font weight for the chart title; overridable per chart. */
11
+ export const TITLE_FONT_WEIGHT: number | string = 600;
12
+ /** Default font size (px) for axis labels (`xLabel` / `yLabel`). */
13
+ export const AXIS_LABEL_FONT_SIZE = 13;
14
+ /** Default font size (px) for axis tick labels (the numbers on each axis). */
15
+ export const TICK_LABEL_FONT_SIZE = 10;
16
+ /** Default fill-opacity for tick labels when no custom color is set. */
17
+ export const TICK_LABEL_OPACITY = 0.6;
18
+ /** Default font size (px) for inline legend item labels. */
19
+ export const LEGEND_FONT_SIZE = 11;
20
+ /** Vertical space below the title's last baseline before the next element. */
21
+ const TITLE_BOTTOM_GAP = 8;
22
+
23
+ /**
24
+ * Resolve a `LabelStyle` against per-element defaults. Returns the
25
+ * fields the chart's `<text>` element needs. When `style.color` is set,
26
+ * the default fill-opacity is dropped so the exact color renders.
27
+ */
28
+ export function resolveLabelStyle(
29
+ style: LabelStyle | undefined,
30
+ defaults: { fontSize: number; fillOpacity?: number },
31
+ ): {
32
+ fontSize: number;
33
+ fill: string;
34
+ fontWeight: number | string | undefined;
35
+ fillOpacity: number | undefined;
36
+ } {
37
+ return {
38
+ fontSize: style?.fontSize ?? defaults.fontSize,
39
+ fill: style?.color ?? "currentColor",
40
+ fontWeight: style?.fontWeight,
41
+ fillOpacity: style?.color != null ? undefined : defaults.fillOpacity,
42
+ };
43
+ }
44
+ /**
45
+ * Estimated character width at font-size 11 used by inline-legend
46
+ * measurement. Matches the value used by LineChart's area-section labels
47
+ * so wrapping behaves consistently across the chart's text overlays.
48
+ */
49
+ const LEGEND_CHAR_WIDTH = 7;
50
+ /** X offset of legend text from the start of its row slot. */
51
+ const LEGEND_TEXT_INDENT = 18;
52
+ /** Horizontal gap between adjacent legend items on the same row. */
53
+ const LEGEND_ITEM_GAP = 16;
54
+
55
+ /**
56
+ * Extra space added around the chart's standard layout. A number applies
57
+ * the same amount to all four sides; an object lets you pad sides
58
+ * independently (e.g. `{ top: 24 }` to make room for annotations above
59
+ * the plot). Named to match Altair's `padding` (outer view padding).
60
+ */
61
+ export type ChartPadding =
62
+ | number
63
+ | {
64
+ top?: number;
65
+ right?: number;
66
+ bottom?: number;
67
+ left?: number;
68
+ };
69
+
70
+ export interface ChartPaddingOptions {
71
+ title: () => string | undefined;
72
+ titleStyle?: () => TitleStyle | undefined;
73
+ xLabel: () => string | undefined;
74
+ yLabel: () => string | undefined;
75
+ /**
76
+ * Labels for the inline legend items, in render order. Empty array
77
+ * (or omitted) means no legend strip. Drives both row-count for
78
+ * reserving top padding and per-item wrap positions.
79
+ */
80
+ inlineLegendLabels: () => readonly string[];
81
+ width: () => number;
82
+ height: () => number;
83
+ /** Extra pixels added on top of the standard axis spacing. */
84
+ extraPadding?: () => ChartPadding | undefined;
85
+ }
86
+
87
+ /** Position of a single legend item within the legend strip. */
88
+ export interface PositionedLegendItem {
89
+ /** X offset relative to `padding.left`. */
90
+ x: number;
91
+ /** Row index, 0-based. */
92
+ row: number;
93
+ }
94
+
95
+ function resolvePadding(p: ChartPadding | undefined) {
96
+ if (p == null) return { top: 0, right: 0, bottom: 0, left: 0 };
97
+ if (typeof p === "number") return { top: p, right: p, bottom: p, left: p };
98
+ return {
99
+ top: p.top ?? 0,
100
+ right: p.right ?? 0,
101
+ bottom: p.bottom ?? 0,
102
+ left: p.left ?? 0,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Estimated rendered width (in px) of a single legend item — indicator,
108
+ * text, and surrounding spacing. Used to flow items into rows.
109
+ */
110
+ function estimateLegendItemWidth(label: string): number {
111
+ return LEGEND_TEXT_INDENT + label.length * LEGEND_CHAR_WIDTH;
112
+ }
113
+
114
+ /**
115
+ * Computes the standard chart padding (top/right/bottom/left) and the
116
+ * derived inner plotting region (innerW, innerH). Shared by LineChart
117
+ * and BarChart so the axis label spacing and inline legend strip stay
118
+ * consistent. `extraPadding` adds extra space outside the plot — useful
119
+ * for annotations that need to extend past the data area without
120
+ * clipping.
121
+ *
122
+ * The inline legend wraps to multiple rows when items don't fit in the
123
+ * available width; `padding.top` grows by `INLINE_LEGEND_ROW_HEIGHT` per
124
+ * row so the plot stays clear of the legend strip.
125
+ */
126
+ export function useChartPadding(opts: ChartPaddingOptions) {
127
+ // Horizontal padding is computed first because `innerW` depends only
128
+ // on left/right (not on legend row count). Splitting padding this way
129
+ // keeps the legend layout — which depends on `innerW` — out of the
130
+ // reactivity cycle for left/right.
131
+ const horizontalPadding = computed(() => {
132
+ const extra = resolvePadding(opts.extraPadding?.());
133
+ return {
134
+ left: (opts.yLabel() ? 56 : 50) + extra.left,
135
+ right: 10 + extra.right,
136
+ };
137
+ });
138
+
139
+ const innerW = computed(
140
+ () =>
141
+ opts.width() -
142
+ horizontalPadding.value.left -
143
+ horizontalPadding.value.right,
144
+ );
145
+
146
+ const inlineLegendLayout = computed<{
147
+ positions: PositionedLegendItem[];
148
+ rowCount: number;
149
+ }>(() => {
150
+ const labels = opts.inlineLegendLabels();
151
+ if (labels.length === 0) return { positions: [], rowCount: 0 };
152
+ const available = Math.max(0, innerW.value);
153
+ const positions: PositionedLegendItem[] = [];
154
+ let row = 0;
155
+ let x = 0;
156
+ for (const label of labels) {
157
+ const w = estimateLegendItemWidth(label);
158
+ // Wrap to a new row when the item won't fit, except when we're
159
+ // already at row-start (`x === 0`): a single oversized label gets
160
+ // its own row instead of triggering an infinite-wrap loop.
161
+ if (x > 0 && x + w > available) {
162
+ row++;
163
+ x = 0;
164
+ }
165
+ positions.push({ x, row });
166
+ x += w + LEGEND_ITEM_GAP;
167
+ }
168
+ return { positions, rowCount: row + 1 };
169
+ });
170
+
171
+ // Title height in px. `\n` in the title creates additional lines, each
172
+ // adding `titleStyle.lineHeight` (default 18). When there is no title,
173
+ // reserve a small top margin so the plot doesn't hug the top edge.
174
+ const titleHeight = computed(() => {
175
+ const t = opts.title();
176
+ if (!t) return 10;
177
+ const lineHeight = opts.titleStyle?.()?.lineHeight ?? TITLE_LINE_HEIGHT;
178
+ const lineCount = t.split("\n").length;
179
+ return lineCount * lineHeight + TITLE_BOTTOM_GAP;
180
+ });
181
+
182
+ const padding = computed(() => {
183
+ const extra = resolvePadding(opts.extraPadding?.());
184
+ const rowCount = inlineLegendLayout.value.rowCount;
185
+ return {
186
+ top: titleHeight.value + rowCount * INLINE_LEGEND_ROW_HEIGHT + extra.top,
187
+ bottom: (opts.xLabel() ? 38 : 30) + extra.bottom,
188
+ left: horizontalPadding.value.left,
189
+ right: horizontalPadding.value.right,
190
+ };
191
+ });
192
+
193
+ // Y-center of the first inline-legend row. Sits at the top of the
194
+ // padding band (just below any title), so any user-supplied
195
+ // `extraPadding.top` becomes empty room between the legend and the
196
+ // plot. Subsequent rows are offset by `INLINE_LEGEND_ROW_HEIGHT`.
197
+ const legendY = computed(
198
+ () => titleHeight.value + INLINE_LEGEND_ROW_HEIGHT / 2,
199
+ );
200
+
201
+ const innerH = computed(
202
+ () => opts.height() - padding.value.top - padding.value.bottom,
203
+ );
204
+ // Pixel-space rect of the plot area. Single source of truth for any
205
+ // consumer that needs to span the full plot (e.g. rule annotations,
206
+ // background fills, clip paths).
207
+ const bounds = computed(() => {
208
+ const p = padding.value;
209
+ return {
210
+ left: p.left,
211
+ top: p.top,
212
+ right: p.left + innerW.value,
213
+ bottom: p.top + innerH.value,
214
+ width: innerW.value,
215
+ height: innerH.value,
216
+ };
217
+ });
218
+ return {
219
+ padding,
220
+ legendY,
221
+ inlineLegendLayout,
222
+ innerW,
223
+ innerH,
224
+ bounds,
225
+ };
226
+ }
227
+
228
+ export type ChartBounds = {
229
+ left: number;
230
+ top: number;
231
+ right: number;
232
+ bottom: number;
233
+ width: number;
234
+ height: number;
235
+ };
@@ -0,0 +1,58 @@
1
+ import { ref, onMounted, onUnmounted, type Ref } from "vue";
2
+
3
+ export interface ChartSizeOptions {
4
+ /** Fallback width when measurement is not yet available. */
5
+ fallbackWidth?: number;
6
+ /** Optional debounce in ms applied to ResizeObserver updates. */
7
+ debounce?: () => number | undefined;
8
+ }
9
+
10
+ /**
11
+ * Watches an element ref for size changes and exposes the measured width.
12
+ * Mirrors the per-chart ResizeObserver wiring previously inlined in each
13
+ * chart component.
14
+ */
15
+ export function useChartSize(opts: ChartSizeOptions = {}) {
16
+ const containerRef = ref<HTMLElement | null>(null);
17
+ const measuredWidth = ref(0);
18
+ const measuredHeight = ref(0);
19
+ let observer: ResizeObserver | null = null;
20
+ let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
21
+
22
+ onMounted(() => {
23
+ if (!containerRef.value) return;
24
+ measuredWidth.value = containerRef.value.clientWidth;
25
+ measuredHeight.value = containerRef.value.clientHeight;
26
+ observer = new ResizeObserver((entries) => {
27
+ const entry = entries[0];
28
+ if (!entry) return;
29
+ const apply = () => {
30
+ measuredWidth.value = entry.contentRect.width;
31
+ measuredHeight.value = entry.contentRect.height;
32
+ };
33
+ const debounce = opts.debounce?.();
34
+ if (debounce) {
35
+ if (resizeTimeout) clearTimeout(resizeTimeout);
36
+ resizeTimeout = setTimeout(apply, debounce);
37
+ } else {
38
+ apply();
39
+ }
40
+ });
41
+ observer.observe(containerRef.value);
42
+ });
43
+
44
+ onUnmounted(() => {
45
+ observer?.disconnect();
46
+ if (resizeTimeout) clearTimeout(resizeTimeout);
47
+ });
48
+
49
+ return {
50
+ containerRef,
51
+ measuredWidth,
52
+ measuredHeight,
53
+ } as {
54
+ containerRef: Ref<HTMLElement | null>;
55
+ measuredWidth: Ref<number>;
56
+ measuredHeight: Ref<number>;
57
+ };
58
+ }
@@ -0,0 +1,205 @@
1
+ import { ref, computed, watch, type Ref } from "vue";
2
+ import { placeTooltip, type TooltipClamp } from "../tooltip-position.js";
3
+
4
+ export interface ChartTooltipOptions {
5
+ /** Whether tooltip interactions are wired up at all. */
6
+ enabled: () => boolean;
7
+ /** Tooltip activation mode. */
8
+ trigger?: () => "hover" | "click" | undefined;
9
+ /** Boundary for tooltip flip/clamp. */
10
+ clamp?: () => TooltipClamp;
11
+ /**
12
+ * Maps a client (x, y) pointer location to a data index, or null when
13
+ * the pointer is outside the chart. Most charts only need `clientX`
14
+ * (categorical or x-indexed), but horizontal bar charts also need
15
+ * `clientY`.
16
+ */
17
+ pointerToIndex: (clientX: number, clientY: number) => number | null;
18
+ /** The chart's container element (used to compute relative position). */
19
+ containerRef: Ref<HTMLElement | null>;
20
+ /** Pointer-vertical offset applied for touch interactions. */
21
+ touchYOffset?: number;
22
+ /**
23
+ * Axis a finger drags *along* to scrub the tooltip. On touch, a drag in
24
+ * the orthogonal direction is left to the browser so the page can still
25
+ * scroll. Defaults to `"x"` (horizontal scrub, vertical page scroll) —
26
+ * correct for time-series line charts and vertical bar charts.
27
+ */
28
+ scrubAxis?: () => "x" | "y";
29
+ /**
30
+ * Emit hover events. The first arg is `{ index }` while hovering and
31
+ * `null` when leaving.
32
+ */
33
+ onHover?: (payload: { index: number } | null) => void;
34
+ }
35
+
36
+ /**
37
+ * Shared tooltip state + pointer/touch handlers used by chart components.
38
+ * The caller wires the returned `handlers` to its hit-test overlay and
39
+ * places the floating tooltip with `tooltipPos`.
40
+ */
41
+ export function useChartTooltip(opts: ChartTooltipOptions) {
42
+ const TOUCH_Y_OFFSET = opts.touchYOffset ?? 50;
43
+ // Pixels a finger must travel before we commit a touch drag to either
44
+ // scrubbing the tooltip or scrolling the page.
45
+ const TOUCH_SLOP = 10;
46
+ const hoverIndex = ref<number | null>(null);
47
+ const isTouching = ref(false);
48
+ const tooltipRef = ref<HTMLElement | null>(null);
49
+ const pointer = ref<{ clientX: number; clientY: number } | null>(null);
50
+ const tooltipPos = ref<{ left: number; top: number } | null>(null);
51
+
52
+ // Touch-gesture tracking (plain locals — they don't drive rendering).
53
+ // "pending" until the drag direction is known; then "scrub" (we own the
54
+ // gesture and move the tooltip) or "scroll" (the browser scrolls the page).
55
+ let touchStart: { x: number; y: number } | null = null;
56
+ let touchMode: "pending" | "scrub" | "scroll" = "pending";
57
+
58
+ function pointerFromEvent(
59
+ event: MouseEvent | TouchEvent,
60
+ ): { clientX: number; clientY: number } | null {
61
+ if ("touches" in event) {
62
+ return event.touches[0] ?? null;
63
+ }
64
+ return event;
65
+ }
66
+
67
+ function updateHover(event: MouseEvent | TouchEvent) {
68
+ const pt = pointerFromEvent(event);
69
+ if (!pt) return;
70
+ const idx = opts.pointerToIndex(pt.clientX, pt.clientY);
71
+ if (idx === null) return;
72
+ hoverIndex.value = idx;
73
+ pointer.value = { clientX: pt.clientX, clientY: pt.clientY };
74
+ opts.onHover?.({ index: idx });
75
+ }
76
+
77
+ watch(
78
+ [pointer, hoverIndex],
79
+ () => {
80
+ if (hoverIndex.value === null || !pointer.value) {
81
+ tooltipPos.value = null;
82
+ return;
83
+ }
84
+ const el = tooltipRef.value;
85
+ const container = opts.containerRef.value;
86
+ if (!el || !container) return;
87
+ const rect = container.getBoundingClientRect();
88
+ const offset = isTouching.value ? TOUCH_Y_OFFSET : 0;
89
+ const clamp = opts.clamp?.() ?? "window";
90
+ const { left, top } = placeTooltip(
91
+ pointer.value.clientX,
92
+ pointer.value.clientY - offset,
93
+ el.offsetWidth,
94
+ el.offsetHeight,
95
+ clamp,
96
+ rect,
97
+ );
98
+ tooltipPos.value = { left: left - rect.left, top: top - rect.top };
99
+ },
100
+ { flush: "post" },
101
+ );
102
+
103
+ function onMouseMove(event: MouseEvent) {
104
+ if (!opts.enabled()) return;
105
+ updateHover(event);
106
+ }
107
+
108
+ function onMouseLeave() {
109
+ if (!opts.enabled()) return;
110
+ if (opts.trigger?.() !== "click") {
111
+ hoverIndex.value = null;
112
+ opts.onHover?.(null);
113
+ }
114
+ }
115
+
116
+ function onClick(event: MouseEvent) {
117
+ if (!opts.enabled()) return;
118
+ if (opts.trigger?.() !== "click") return;
119
+ const pt = pointerFromEvent(event);
120
+ if (!pt) return;
121
+ const idx = opts.pointerToIndex(pt.clientX, pt.clientY);
122
+ if (idx === null) return;
123
+ hoverIndex.value = hoverIndex.value === idx ? null : idx;
124
+ opts.onHover?.(hoverIndex.value !== null ? { index: idx } : null);
125
+ }
126
+
127
+ function onTouchStart(event: TouchEvent) {
128
+ if (!opts.enabled()) return;
129
+ const pt = pointerFromEvent(event);
130
+ if (!pt) return;
131
+ // Don't preventDefault here: that would cancel the browser's scroll for
132
+ // the whole gesture before we know its direction. We decide on the
133
+ // first move instead. (`touch-action` on the overlay caps which
134
+ // direction the browser may still claim.)
135
+ touchStart = { x: pt.clientX, y: pt.clientY };
136
+ touchMode = "pending";
137
+ isTouching.value = true;
138
+ updateHover(event);
139
+ }
140
+
141
+ function onTouchMove(event: TouchEvent) {
142
+ if (!opts.enabled()) return;
143
+ // The page owns this gesture — let it scroll, leave the tooltip hidden.
144
+ if (touchMode === "scroll") return;
145
+
146
+ if (touchMode === "pending") {
147
+ const pt = pointerFromEvent(event);
148
+ if (!pt || !touchStart) return;
149
+ const dx = Math.abs(pt.clientX - touchStart.x);
150
+ const dy = Math.abs(pt.clientY - touchStart.y);
151
+ if (dx + dy < TOUCH_SLOP) {
152
+ // Too small to classify — track under the finger, but don't yet
153
+ // preventDefault so the browser can still start a scroll.
154
+ updateHover(event);
155
+ return;
156
+ }
157
+ const scrubAxis = opts.scrubAxis?.() ?? "x";
158
+ const isScrub = scrubAxis === "x" ? dx >= dy : dy >= dx;
159
+ if (!isScrub) {
160
+ // Drag is across the scrub axis — hand the gesture to the browser.
161
+ touchMode = "scroll";
162
+ hoverIndex.value = null;
163
+ opts.onHover?.(null);
164
+ return;
165
+ }
166
+ touchMode = "scrub";
167
+ }
168
+
169
+ // Scrubbing: we own the gesture, so suppress the native scroll.
170
+ event.preventDefault();
171
+ updateHover(event);
172
+ }
173
+
174
+ function onTouchEnd() {
175
+ if (!opts.enabled()) return;
176
+ isTouching.value = false;
177
+ touchStart = null;
178
+ touchMode = "pending";
179
+ hoverIndex.value = null;
180
+ opts.onHover?.(null);
181
+ }
182
+
183
+ // Note: when binding via `v-on="handlers"`, Vue expects event names
184
+ // *without* the `on` prefix. The touch handlers call `preventDefault`
185
+ // internally (only while scrubbing), so the overlay must NOT bind them
186
+ // passively, and should set `touch-action` to the scroll direction it
187
+ // wants to keep (e.g. `pan-y` when `scrubAxis` is `"x"`).
188
+ const handlers = {
189
+ mousemove: onMouseMove,
190
+ mouseleave: onMouseLeave,
191
+ click: onClick,
192
+ touchstart: onTouchStart,
193
+ touchmove: onTouchMove,
194
+ touchend: onTouchEnd,
195
+ };
196
+
197
+ return {
198
+ hoverIndex,
199
+ isTouching,
200
+ pointer,
201
+ tooltipRef,
202
+ tooltipPos,
203
+ handlers,
204
+ };
205
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Ambient declaration for side-effect CSS imports (e.g. `import "./x.css"`).
2
+ // Required since TypeScript 6, which errors (TS2882) on side-effect imports
3
+ // without a module declaration.
4
+ declare module "*.css";
@@ -0,0 +1 @@
1
+ export { fipsToHsa, hsaNames } from "./ChoroplethMap/hsaMapping.js";
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export {
2
+ default as LineChart,
3
+ type LineChartData,
4
+ type Series,
5
+ type Area,
6
+ type AreaSection,
7
+ } from "./LineChart/LineChart.vue";
8
+ export {
9
+ default as BarChart,
10
+ type BarChartData,
11
+ type BarSeries,
12
+ type BarSummaryLine,
13
+ } from "./BarChart/BarChart.vue";
14
+ export {
15
+ default as ChoroplethMap,
16
+ type GeoType,
17
+ type StateData,
18
+ type ChoroplethColorScale,
19
+ type ThresholdStop,
20
+ type CategoricalStop,
21
+ type FocusItem,
22
+ type FocusValue,
23
+ type FocusStyle,
24
+ } from "./ChoroplethMap/ChoroplethMap.vue";
25
+ export { default as ChartTooltip } from "./ChartTooltip/ChartTooltip.vue";
26
+ export {
27
+ default as DataTable,
28
+ type TableData,
29
+ type TableRecord,
30
+ type ColumnAlign,
31
+ type ColumnConfig,
32
+ type ColumnWidth,
33
+ } from "./DataTable/DataTable.vue";
34
+ export type { CityMarker, PlacedCity } from "./ChoroplethMap/cityLayout.js";
35
+ export {
36
+ mapThemeDefaults,
37
+ type MapTheme,
38
+ type ResolvedMapTheme,
39
+ } from "./_shared/mapTheme.js";
40
+ export type { ChartAnnotation } from "./_shared/annotations.js";
41
+ export type { BlendMode, LineMarkStyle } from "./_shared/chartProps.js";
@@ -0,0 +1,55 @@
1
+ export type TooltipClamp = "none" | "chart" | "window";
2
+
3
+ const GAP = 16;
4
+ const PAD = 8;
5
+
6
+ /**
7
+ * Computes a tooltip position in client coordinates, flipping to the opposite
8
+ * side of the pointer and clamping vertically when the preferred placement
9
+ * would overflow the clamp boundary.
10
+ *
11
+ * The tooltip is assumed to be anchored with `transform: translateY(-50%)`,
12
+ * so `top` is the vertical center.
13
+ */
14
+ export function placeTooltip(
15
+ clientX: number,
16
+ centerY: number,
17
+ tipW: number,
18
+ tipH: number,
19
+ clamp: TooltipClamp,
20
+ chartRect?: DOMRect,
21
+ ): { left: number; top: number } {
22
+ if (clamp === "none") {
23
+ return { left: clientX + GAP, top: centerY };
24
+ }
25
+
26
+ const bounds =
27
+ clamp === "chart" && chartRect
28
+ ? {
29
+ left: chartRect.left,
30
+ right: chartRect.right,
31
+ top: chartRect.top,
32
+ bottom: chartRect.bottom,
33
+ }
34
+ : {
35
+ left: 0,
36
+ right: window.innerWidth,
37
+ top: 0,
38
+ bottom: window.innerHeight,
39
+ };
40
+
41
+ const flip = clientX + GAP + tipW > bounds.right - PAD;
42
+ const rawLeft = flip ? clientX - GAP - tipW : clientX + GAP;
43
+ // Clamp horizontally too: a left-flip near a narrow boundary's left edge
44
+ // (or a right placement in a chart narrower than the tooltip) must not
45
+ // overflow the far side. The upper bound is floored to the lower one so a
46
+ // tooltip wider than the bounds still pins to the left edge.
47
+ const maxLeft = Math.max(bounds.left + PAD, bounds.right - PAD - tipW);
48
+ const left = Math.min(Math.max(rawLeft, bounds.left + PAD), maxLeft);
49
+ const halfH = tipH / 2;
50
+ const top = Math.min(
51
+ Math.max(centerY, bounds.top + PAD + halfH),
52
+ bounds.bottom - PAD - halfH,
53
+ );
54
+ return { left, top };
55
+ }