@cfasim-ui/docs 0.7.3 → 0.7.5

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.
@@ -8,20 +8,36 @@ export interface ChoroplethTooltipData {
8
8
  feature: unknown;
9
9
  }
10
10
 
11
- defineSlots<{
12
- default?(props: ChoroplethTooltipData): unknown;
13
- }>();
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
+ );
14
25
 
15
26
  // Local reactive state. Held inside the child so the parent's render scope
16
27
  // never subscribes to it — hover updates re-render only this small tree,
17
28
  // not the parent's 3,000+ paths.
18
29
  const data = ref<ChoroplethTooltipData | null>(null);
30
+ const open = ref(false);
19
31
  const rootRef = useTemplateRef<HTMLDivElement>("root");
20
32
 
21
33
  defineExpose({
22
34
  setData(next: ChoroplethTooltipData | null) {
23
35
  data.value = next;
24
36
  },
37
+ /** Sheet-mode visibility (float mode is driven imperatively via getEl). */
38
+ setOpen(next: boolean) {
39
+ open.value = next;
40
+ },
25
41
  getEl(): HTMLDivElement | null {
26
42
  return rootRef.value;
27
43
  },
@@ -29,6 +45,10 @@ defineExpose({
29
45
  </script>
30
46
 
31
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). -->
32
52
  <Teleport to="body">
33
53
  <div
34
54
  ref="root"
@@ -37,13 +57,47 @@ defineExpose({
37
57
  position: fixed;
38
58
  left: 0;
39
59
  top: 0;
60
+ z-index: calc(var(--cfasim-z-fullscreen, 1000) + 1);
40
61
  visibility: hidden;
41
62
  will-change: transform;
42
63
  pointer-events: none;
43
64
  transform: translateY(-50%);
44
65
  "
45
66
  >
46
- <slot v-if="data" v-bind="data" />
67
+ <slot v-if="data && mode === 'float'" v-bind="data" />
47
68
  </div>
48
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>
49
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,319 @@
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 outline concatenated into one path — features share a
21
+ * stroke color, and one native stroke call beats thousands (WebKit's
22
+ * canvas2D especially).
23
+ */
24
+ outlines: Path2D | null;
25
+ /** State-borders mesh (counties / hsas mode). */
26
+ borders: Path2D | null;
27
+ }
28
+
29
+ export interface CanvasView {
30
+ dpr: number;
31
+ /** Uniform viewBox→CSS scale (xMidYMid meet). */
32
+ meetScale: number;
33
+ /** CSS-px letterbox offsets inside the canvas box. */
34
+ offsetX: number;
35
+ offsetY: number;
36
+ zoom: { k: number; x: number; y: number };
37
+ }
38
+
39
+ export interface CanvasOverlayItem {
40
+ path: Path2D;
41
+ stroke: string;
42
+ strokeWidth?: number;
43
+ style?: CanvasDashStyle;
44
+ }
45
+
46
+ export interface CanvasHighlightItem {
47
+ stroke?: string;
48
+ strokeWidth?: number;
49
+ style?: CanvasDashStyle;
50
+ }
51
+
52
+ export interface CanvasBaseState {
53
+ strokeColor: string;
54
+ /** Base feature stroke width in CSS px (effectiveStrokeWidth). */
55
+ strokeWidth: number;
56
+ /** Resolved highlight color (light-dark() can't paint on canvas). */
57
+ highlightStroke: string;
58
+ focused: Map<string, CanvasHighlightItem>;
59
+ overlays: CanvasOverlayItem[];
60
+ }
61
+
62
+ export interface CanvasDrawState extends CanvasBaseState {
63
+ hoveredId: string | null;
64
+ }
65
+
66
+ function applyViewTransform(
67
+ ctx: CanvasRenderingContext2D,
68
+ view: CanvasView,
69
+ ): void {
70
+ const s = view.dpr * view.meetScale * view.zoom.k;
71
+ ctx.setTransform(
72
+ s,
73
+ 0,
74
+ 0,
75
+ s,
76
+ view.dpr * (view.offsetX + view.meetScale * view.zoom.x),
77
+ view.dpr * (view.offsetY + view.meetScale * view.zoom.y),
78
+ );
79
+ }
80
+
81
+ /** Line width (map units) rendering as `css` CSS px on screen. */
82
+ function lwFor(view: CanvasView): (css: number) => number {
83
+ return (css) => css / (view.meetScale * view.zoom.k || 1);
84
+ }
85
+
86
+ function applyLineDash(
87
+ ctx: CanvasRenderingContext2D,
88
+ lw: (css: number) => number,
89
+ style: CanvasDashStyle | undefined,
90
+ ): void {
91
+ if (style === "dashed") {
92
+ ctx.setLineDash([lw(8), lw(4)]);
93
+ ctx.lineCap = "butt";
94
+ } else if (style === "dotted") {
95
+ ctx.setLineDash([0, lw(5)]);
96
+ ctx.lineCap = "round";
97
+ } else {
98
+ ctx.setLineDash([]);
99
+ ctx.lineCap = "butt";
100
+ }
101
+ }
102
+
103
+ export function buildScene(
104
+ features: Array<{ id?: string | number | null }>,
105
+ pathFor: (feature: never) => string | null,
106
+ colorFor: (id: string) => string,
107
+ bordersD?: string | null,
108
+ ): CanvasScene {
109
+ const items: SceneItem[] = [];
110
+ const indexById = new Map<string, number>();
111
+ const outlines = new Path2D();
112
+ let outlineCount = 0;
113
+ for (const feat of features) {
114
+ const d = pathFor(feat as never);
115
+ if (!d) continue;
116
+ const id = String(feat.id);
117
+ const path = new Path2D(d);
118
+ indexById.set(id, items.length);
119
+ items.push({ id, path, fill: colorFor(id) });
120
+ if (typeof outlines.addPath === "function") {
121
+ outlines.addPath(path);
122
+ outlineCount++;
123
+ }
124
+ }
125
+ return {
126
+ items,
127
+ indexById,
128
+ outlines: outlineCount ? outlines : null,
129
+ borders: bordersD ? new Path2D(bordersD) : null,
130
+ };
131
+ }
132
+
133
+ function highlightOne(
134
+ ctx: CanvasRenderingContext2D,
135
+ scene: CanvasScene,
136
+ lw: (css: number) => number,
137
+ state: CanvasBaseState,
138
+ id: string,
139
+ item?: CanvasHighlightItem,
140
+ ): void {
141
+ const idx = scene.indexById.get(id);
142
+ if (idx == null) return;
143
+ const it = scene.items[idx];
144
+ // Re-filling first covers neighbors' strokes — the canvas equivalent of
145
+ // the SVG renderer's appendChild raise.
146
+ ctx.fillStyle = it.fill;
147
+ ctx.fill(it.path);
148
+ ctx.strokeStyle = item?.stroke ?? state.highlightStroke;
149
+ ctx.lineWidth = lw(item?.strokeWidth ?? state.strokeWidth + 1);
150
+ applyLineDash(ctx, lw, item?.style);
151
+ ctx.stroke(it.path);
152
+ }
153
+
154
+ /**
155
+ * Base pass internals composed by drawScene. The transform maps canonical
156
+ * viewBox coordinates to device pixels:
157
+ * `device = dpr · (letterboxOffset + meetScale · zoom(point))`. Stroke
158
+ * widths are compensated so a visual width `w` CSS px stays constant at
159
+ * any zoom, mirroring the SVG renderer's `strokeDivisor`.
160
+ */
161
+ function beginBasePass(ctx: CanvasRenderingContext2D, view: CanvasView): void {
162
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
163
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
164
+ applyViewTransform(ctx, view);
165
+ ctx.lineJoin = "round";
166
+ ctx.setLineDash([]);
167
+ }
168
+
169
+ /** Fills items [from, to); returns the next index. The ctx keeps the
170
+ * transform beginBasePass applied, so slices can span multiple frames. */
171
+ function drawFillSlice(
172
+ ctx: CanvasRenderingContext2D,
173
+ scene: CanvasScene,
174
+ from: number,
175
+ to: number,
176
+ ): number {
177
+ const end = Math.min(to, scene.items.length);
178
+ for (let i = from; i < end; i++) {
179
+ ctx.fillStyle = scene.items[i].fill;
180
+ ctx.fill(scene.items[i].path);
181
+ }
182
+ return end;
183
+ }
184
+
185
+ /** Outlines, borders, focus highlights, and overlays — everything but the
186
+ * transient hover, which the display frame draws live on top of blits. */
187
+ function finishBasePass(
188
+ ctx: CanvasRenderingContext2D,
189
+ scene: CanvasScene,
190
+ view: CanvasView,
191
+ state: CanvasBaseState,
192
+ ): void {
193
+ const lw = lwFor(view);
194
+ ctx.setLineDash([]);
195
+ ctx.lineWidth = lw(state.strokeWidth);
196
+ ctx.strokeStyle = state.strokeColor;
197
+ if (scene.outlines) {
198
+ ctx.stroke(scene.outlines);
199
+ } else {
200
+ for (const item of scene.items) ctx.stroke(item.path);
201
+ }
202
+
203
+ if (scene.borders) {
204
+ ctx.lineWidth = lw(1);
205
+ ctx.stroke(scene.borders);
206
+ }
207
+
208
+ for (const [id, item] of state.focused) {
209
+ highlightOne(ctx, scene, lw, state, id, item);
210
+ }
211
+
212
+ // Overlays above the focus highlights, matching the SVG z-order.
213
+ for (const o of state.overlays) {
214
+ ctx.strokeStyle = o.stroke;
215
+ ctx.lineWidth = lw(o.strokeWidth ?? state.strokeWidth + 1.5);
216
+ applyLineDash(ctx, lw, o.style);
217
+ ctx.stroke(o.path);
218
+ }
219
+ ctx.setLineDash([]);
220
+ }
221
+
222
+ /** Live hover highlight, drawn by the display frame on top of the blitted
223
+ * base (focused features are already highlighted in the base itself). */
224
+ export function drawHoverHighlight(
225
+ ctx: CanvasRenderingContext2D,
226
+ scene: CanvasScene,
227
+ view: CanvasView,
228
+ state: CanvasBaseState,
229
+ hoveredId: string | null,
230
+ ): void {
231
+ if (!hoveredId || state.focused.has(hoveredId)) return;
232
+ applyViewTransform(ctx, view);
233
+ ctx.lineJoin = "round";
234
+ highlightOne(ctx, scene, lwFor(view), state, hoveredId);
235
+ ctx.setLineDash([]);
236
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
237
+ }
238
+
239
+ /** One-shot full repaint (composition of the passes above). */
240
+ export function drawScene(
241
+ ctx: CanvasRenderingContext2D,
242
+ scene: CanvasScene,
243
+ view: CanvasView,
244
+ state: CanvasDrawState,
245
+ ): void {
246
+ beginBasePass(ctx, view);
247
+ drawFillSlice(ctx, scene, 0, scene.items.length);
248
+ finishBasePass(ctx, scene, view, state);
249
+ drawHoverHighlight(ctx, scene, view, state, state.hoveredId);
250
+ }
251
+
252
+ /** Unique fill color for feature index `i` on the picking canvas. */
253
+ export function indexToColor(i: number): string {
254
+ const v = i + 1;
255
+ return `rgb(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255})`;
256
+ }
257
+
258
+ /**
259
+ * Paints every feature in a unique index color onto `canvas` at canonical
260
+ * viewBox resolution. Rebuilt only when the geometry changes; hit-testing
261
+ * then reads one pixel per query regardless of feature count.
262
+ */
263
+ export function buildPicking(
264
+ scene: CanvasScene,
265
+ width: number,
266
+ height: number,
267
+ canvas: HTMLCanvasElement,
268
+ ): CanvasRenderingContext2D | null {
269
+ canvas.width = Math.max(1, Math.round(width));
270
+ canvas.height = Math.max(1, Math.round(height));
271
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
272
+ if (!ctx) return null;
273
+ ctx.imageSmoothingEnabled = false;
274
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
275
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
276
+ scene.items.forEach((item, i) => {
277
+ ctx.fillStyle = indexToColor(i);
278
+ ctx.fill(item.path);
279
+ });
280
+ return ctx;
281
+ }
282
+
283
+ /**
284
+ * Feature index at canonical viewBox coordinates (the caller has already
285
+ * inverted the zoom transform), or null over the background. Fill edges
286
+ * antialias into blended colors, so a candidate whose decoded index is
287
+ * implausible — or that fails a point-in-path check — falls back to a
288
+ * linear `isPointInPath` scan (rare, borders only).
289
+ */
290
+ export function pickIndexAt(
291
+ ctx: CanvasRenderingContext2D,
292
+ scene: CanvasScene,
293
+ x: number,
294
+ y: number,
295
+ ): number | null {
296
+ const px = Math.round(x);
297
+ const py = Math.round(y);
298
+ if (px < 0 || py < 0 || px >= ctx.canvas.width || py >= ctx.canvas.height) {
299
+ return null;
300
+ }
301
+ const d = ctx.getImageData(px, py, 1, 1).data;
302
+ if (d[3] === 0) return null;
303
+ const idx = ((d[0] << 16) | (d[1] << 8) | d[2]) - 1;
304
+ if (idx >= 0 && idx < scene.items.length) {
305
+ if (
306
+ typeof ctx.isPointInPath !== "function" ||
307
+ ctx.isPointInPath(scene.items[idx].path, x, y)
308
+ ) {
309
+ return idx;
310
+ }
311
+ }
312
+ // Antialiased edge pixel: verify the hard way.
313
+ if (typeof ctx.isPointInPath === "function") {
314
+ for (let i = 0; i < scene.items.length; i++) {
315
+ if (ctx.isPointInPath(scene.items[i].path, x, y)) return i;
316
+ }
317
+ }
318
+ return null;
319
+ }
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.3",
2
+ "version": "0.7.5",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {