v-uixy 1.9.1 → 1.9.2

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.
@@ -1,308 +1,981 @@
1
1
  <template>
2
- <div class="relative w-full h-full">
2
+ <div
3
+ ref="wrapperRef"
4
+ class="relative h-full w-full"
5
+ :class="props.interactive ? 'cursor-crosshair touch-pan-y' : ''"
6
+ :tabindex="props.interactive ? 0 : undefined"
7
+ :role="props.interactive ? 'group' : 'img'"
8
+ :aria-roledescription="props.interactive ? 'chart' : undefined"
9
+ :aria-label="ariaLabel"
10
+ @pointermove="handlePointerMove"
11
+ @pointerdown="handlePointerDown"
12
+ @pointerleave="handlePointerLeave"
13
+ @pointercancel="handlePointerCancel"
14
+ @focus="handleFocus"
15
+ @blur="handleBlur"
16
+ @keydown="handleKeydown"
17
+ >
3
18
  <svg
4
- width="100%"
5
- height="100%"
6
- :viewBox="`0 0 ${vbWidth} ${vbHeight}`"
7
- preserveAspectRatio="xMidYMid meet"
8
- role="img"
9
- aria-label="Pixel chart"
10
- class="block select-none pointer-events-none overflow-visible"
11
- shape-rendering="crispEdges"
19
+ v-if="ready"
20
+ :width="chartWidth"
21
+ :height="chartHeight"
22
+ :viewBox="`0 0 ${chartWidth} ${chartHeight}`"
23
+ aria-hidden="true"
24
+ class="block select-none overflow-visible"
12
25
  >
13
- <rect
14
- v-for="cell in renderCells"
15
- :key="`base-${cell.key}`"
16
- :x="cell.x"
17
- :y="cell.y"
18
- :width="CELL"
19
- :height="CELL"
20
- class="fill-gray-200 dark:fill-gray-900"
21
- />
26
+ <defs>
27
+ <pattern
28
+ :id="patternId"
29
+ patternUnits="userSpaceOnUse"
30
+ :x="gridX"
31
+ :y="gridY"
32
+ :width="cellSize + GAP"
33
+ :height="cellSize + GAP"
34
+ >
35
+ <rect
36
+ :width="cellSize"
37
+ :height="cellSize"
38
+ class="fill-gray-200 dark:fill-gray-900"
39
+ shape-rendering="crispEdges"
40
+ />
41
+ </pattern>
42
+ </defs>
22
43
 
23
44
  <rect
24
- v-for="cell in renderCells"
25
- :key="`color-${cell.key}`"
26
- :x="cell.x"
27
- :y="cell.y"
28
- :width="CELL"
29
- :height="CELL"
30
- :class="cell.cls"
31
- :fill="cell.fill"
32
- :style="{
33
- opacity: cell.filled ? 1 : 0,
34
- transition: props.animate
35
- ? `opacity ${REVEAL_DURATION}s ease-out`
36
- : 'none',
37
- }"
45
+ :x="gridX"
46
+ :y="gridY"
47
+ :width="gridWidth"
48
+ :height="gridHeight"
49
+ :fill="`url(#${patternId})`"
38
50
  />
51
+
52
+ <g v-if="props.axis" class="pointer-events-none">
53
+ <template v-for="tick in yTicks" :key="tick.value">
54
+ <text
55
+ :x="yLabelX"
56
+ :y="tick.y"
57
+ text-anchor="end"
58
+ dominant-baseline="middle"
59
+ class="fill-gray-500 text-[11px] font-500 tabular-nums dark:fill-gray-600"
60
+ >
61
+ {{ tick.label }}
62
+ </text>
63
+ <rect
64
+ :x="yDotX - 1"
65
+ :y="tick.y - 1"
66
+ width="2"
67
+ height="2"
68
+ class="fill-gray-400 dark:fill-gray-700"
69
+ shape-rendering="crispEdges"
70
+ />
71
+ </template>
72
+ </g>
73
+
74
+ <g
75
+ v-if="props.interactive"
76
+ class="pointer-events-none"
77
+ :style="followStyle"
78
+ >
79
+ <rect
80
+ :x="-(cellSize + GAP) / 2"
81
+ :y="gridY"
82
+ :width="cellSize + GAP"
83
+ :height="gridHeight"
84
+ class="fill-black/[0.04] dark:fill-white/[0.06]"
85
+ />
86
+ </g>
87
+
88
+ <g class="pointer-events-none" shape-rendering="crispEdges">
89
+ <rect
90
+ v-for="cell in cells"
91
+ :key="cell.key"
92
+ :x="cellX(cell.col)"
93
+ :y="cellY(cell.row)"
94
+ :width="cellSize"
95
+ :height="cellSize"
96
+ :class="cell.className"
97
+ :fill="cell.fill"
98
+ :style="{
99
+ opacity: cell.filled ? 1 : 0,
100
+ transition: revealTransition,
101
+ }"
102
+ />
103
+ </g>
104
+
105
+ <g
106
+ v-if="props.interactive"
107
+ class="pointer-events-none"
108
+ :style="followStyle"
109
+ >
110
+ <line
111
+ x1="0"
112
+ x2="0"
113
+ :y1="gridY"
114
+ :y2="gridBottom"
115
+ stroke-width="1"
116
+ stroke-dasharray="2 4"
117
+ class="stroke-gray-500 dark:stroke-gray-600"
118
+ />
119
+ <g :style="markerStyle">
120
+ <circle
121
+ r="4.5"
122
+ stroke-width="2"
123
+ class="fill-black stroke-white dark:fill-white dark:stroke-black"
124
+ />
125
+ </g>
126
+ </g>
127
+
128
+ <g v-if="props.axis" class="pointer-events-none">
129
+ <text
130
+ v-for="tick in xTicks"
131
+ :key="tick.start"
132
+ :x="tick.x"
133
+ :y="xLabelY"
134
+ text-anchor="middle"
135
+ dominant-baseline="middle"
136
+ class="text-[11px] font-500 tracking-[0.08em] transition-[fill] duration-150"
137
+ :class="
138
+ tick.active
139
+ ? 'fill-black dark:fill-white'
140
+ : 'fill-gray-500 dark:fill-gray-600'
141
+ "
142
+ >
143
+ {{ tick.label }}
144
+ </text>
145
+ <rect
146
+ v-for="x in xSeparators"
147
+ :key="x"
148
+ :x="x - 1"
149
+ :y="xLabelY - 1"
150
+ width="2"
151
+ height="2"
152
+ class="fill-gray-400 dark:fill-gray-700"
153
+ shape-rendering="crispEdges"
154
+ />
155
+ </g>
39
156
  </svg>
157
+
158
+ <div
159
+ v-if="props.interactive"
160
+ ref="tooltipRef"
161
+ class="pointer-events-none absolute top-0 left-0 z-10 origin-top-left"
162
+ :style="tooltipStyle"
163
+ aria-hidden="true"
164
+ >
165
+ <div
166
+ class="min-w-[168px] rounded-3 border border-solid border-gray-300 bg-white p-1 shadow-lg dark:border-gray-900 dark:bg-gray-1000"
167
+ >
168
+ <div
169
+ class="rounded-2 bg-gray-200 px-2 py-1 text-sm font-500 text-gray-600 dark:bg-gray-900 dark:text-gray-500"
170
+ >
171
+ {{ tooltipTitle }}
172
+ </div>
173
+
174
+ <div class="flex flex-col gap-1 px-2 py-2">
175
+ <div
176
+ v-for="(row, index) in tooltipRows"
177
+ :key="index"
178
+ class="flex items-center gap-2"
179
+ >
180
+ <span
181
+ class="size-1.5 shrink-0 rounded-full"
182
+ :class="row.swatchClass"
183
+ :style="row.swatchStyle"
184
+ />
185
+ <span class="text-sm text-gray-600 dark:text-gray-500">
186
+ {{ row.label }}
187
+ </span>
188
+ <span
189
+ class="ml-auto text-sm font-600 tabular-nums text-black dark:text-white"
190
+ >
191
+ {{ row.value }}
192
+ </span>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+
198
+ <span v-if="props.interactive" class="sr-only" aria-live="polite">
199
+ {{ announcement }}
200
+ </span>
40
201
  </div>
41
202
  </template>
42
203
 
43
204
  <script setup lang="ts">
44
- import { computed, onUnmounted, ref, watch } from "vue";
205
+ import { computed, onMounted, onUnmounted, ref, useId, watch } from "vue";
45
206
  import type { UixyChartProps, UixyChartSeries } from "./Chart.types";
46
207
 
47
- const CELL = 12;
208
+ type Point = { x: number; y: number };
209
+
210
+ type Paint = { fill?: string; className?: string };
211
+
212
+ type Cell = Paint & {
213
+ key: string;
214
+ col: number;
215
+ row: number;
216
+ filled: boolean;
217
+ };
218
+
219
+ type Segment = { series: number; start: number; end: number };
220
+
221
+ type Stack = { segments: Segment[]; height: number };
222
+
223
+ type LabelRun = { label: string; start: number; end: number };
224
+
225
+ type Unit = { divisor: number; suffix: string };
226
+
227
+ type Batch = { timer: ReturnType<typeof setTimeout>; tasks: (() => void)[] };
228
+
48
229
  const GAP = 2;
230
+ const FADE_ROWS = 2;
231
+ const TOP_HEADROOM = 0.08;
49
232
 
50
233
  const COLUMN_STEP = 0.05;
51
234
  const ROW_STEP = 0.04;
52
235
  const REVEAL_DURATION = 0.18;
53
236
 
54
- const TOP_HEADROOM = 0.15;
55
- const FADE_ROWS = 2;
237
+ const MOVE_TRANSITION = "transform 0.18s cubic-bezier(0.22, 1, 0.36, 1)";
238
+ const FADE_TRANSITION = "opacity 0.12s ease-out";
239
+ const SCALE_TRANSITION = "scale 0.12s ease-out";
240
+
241
+ const CHAR_WIDTH = 6.6;
242
+ const X_AXIS_HEIGHT = 26;
243
+ const X_LABEL_OFFSET = 16;
244
+ const X_LABEL_PADDING = 24;
245
+ const Y_LABEL_GAP = 16;
246
+ const Y_DOT_GAP = 8;
247
+ const Y_TICK_MIN_SPACING = 26;
248
+
249
+ const TOOLTIP_OFFSET = 14;
250
+ const TOOLTIP_MARGIN = 4;
251
+
252
+ const HEX = /^#?([\da-f]{3}|[\da-f]{6})$/i;
253
+
254
+ const UNITS: Unit[] = [
255
+ { divisor: 1e9, suffix: "B" },
256
+ { divisor: 1e6, suffix: "M" },
257
+ { divisor: 1e3, suffix: "k" },
258
+ ];
56
259
 
57
- const DEFAULT_CLASSES = [
260
+ const FADE = [
58
261
  "fill-gray-500 dark:fill-gray-600",
59
262
  "fill-gray-800 dark:fill-gray-300",
60
263
  "fill-gray-900 dark:fill-gray-200",
61
- "fill-black dark:fill-white",
264
+ ];
265
+
266
+ const PALETTE = [
267
+ { fill: "fill-black dark:fill-white", swatch: "bg-black dark:bg-white" },
268
+ {
269
+ fill: "fill-gray-400 dark:fill-gray-600",
270
+ swatch: "bg-gray-400 dark:bg-gray-600",
271
+ },
272
+ {
273
+ fill: "fill-gray-600 dark:fill-gray-500",
274
+ swatch: "bg-gray-600 dark:bg-gray-500",
275
+ },
62
276
  ];
63
277
 
64
278
  const props = withDefaults(defineProps<UixyChartProps>(), {
65
279
  rows: 8,
66
280
  animate: true,
281
+ axis: true,
282
+ interactive: true,
67
283
  });
68
284
 
69
- type Paint = { fill?: string; cls?: string };
285
+ const patternId = `uixy-chart-grid-${useId()}`;
70
286
 
71
- type Cell = {
72
- key: string;
73
- x: number;
74
- y: number;
75
- filled: boolean;
76
- fill?: string;
77
- cls?: string;
287
+ const wrapperRef = ref<HTMLElement | null>(null);
288
+ const tooltipRef = ref<HTMLElement | null>(null);
289
+
290
+ const chartWidth = ref(0);
291
+ const parentHeight = ref(0);
292
+ const heightFromParent = ref<boolean | null>(null);
293
+ const tooltipSize = ref({ width: 168, height: 92 });
294
+
295
+ const hoveredColumn = ref<number | null>(null);
296
+ const focusedColumn = ref<number | null>(null);
297
+
298
+ const cells = ref<Cell[]>([]);
299
+
300
+ const crosshairAt = ref<Point>({ x: 0, y: 0 });
301
+ const tooltipAt = ref<Point>({ x: 0, y: 0 });
302
+ const instant = ref(true);
303
+
304
+ const clamp = (value: number, min: number, max: number) =>
305
+ Math.max(min, Math.min(max, value));
306
+
307
+ const valueAt = (entry: UixyChartSeries, col: number) =>
308
+ Math.max(0, entry.values[col] ?? 0);
309
+
310
+ const series = computed<UixyChartSeries[]>(() => {
311
+ if (props.series?.length) return props.series;
312
+ if (props.data?.length)
313
+ return [{ label: "Value", values: props.data.map((d) => d.value) }];
314
+ return [];
315
+ });
316
+
317
+ const labels = computed<string[]>(() => {
318
+ if (props.labels?.length) return props.labels;
319
+ if (props.data?.length) return props.data.map((d) => d.label);
320
+ return [];
321
+ });
322
+
323
+ const rows = computed(() => Math.max(1, Math.floor(props.rows)));
324
+
325
+ const columnCount = computed(() =>
326
+ Math.max(0, ...series.value.map((entry) => entry.values.length)),
327
+ );
328
+
329
+ const maxTotal = computed(() =>
330
+ Math.max(
331
+ 0,
332
+ ...Array.from({ length: columnCount.value }, (_, col) =>
333
+ series.value.reduce((sum, entry) => sum + valueAt(entry, col), 0),
334
+ ),
335
+ ),
336
+ );
337
+
338
+ const niceStep = (raw: number) => {
339
+ if (raw <= 0) return 1;
340
+
341
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
342
+ const fraction = raw / magnitude;
343
+ const nice =
344
+ fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10;
345
+
346
+ return nice * magnitude;
78
347
  };
79
348
 
349
+ const yScale = computed(() => {
350
+ const target = maxTotal.value * (1 + TOP_HEADROOM) || 1;
351
+ let best = { steps: 0, step: 0, max: Infinity };
352
+
353
+ for (const steps of [3, 4, 5, 6]) {
354
+ const step = niceStep(target / steps);
355
+ const max = step * steps;
356
+
357
+ if (max < best.max || (max === best.max && steps > best.steps))
358
+ best = { steps, step, max };
359
+ }
360
+
361
+ return best;
362
+ });
363
+
364
+ const yTickValues = computed(() =>
365
+ Array.from(
366
+ { length: yScale.value.steps + 1 },
367
+ (_, index) => index * yScale.value.step,
368
+ ),
369
+ );
370
+
371
+ const unitFor = (value: number): Unit =>
372
+ UNITS.find((unit) => value >= unit.divisor) ?? { divisor: 1, suffix: "" };
373
+
374
+ const compact = (value: number, { divisor, suffix }: Unit) => {
375
+ const scaled = value / divisor;
376
+ const text =
377
+ Number.isInteger(scaled) || Math.abs(scaled) >= 100
378
+ ? String(Math.round(scaled))
379
+ : scaled.toFixed(1).replace(/\.0$/, "");
380
+
381
+ return `${text}${suffix}`;
382
+ };
383
+
384
+ const tickUnit = computed(() => unitFor(yScale.value.max));
385
+
386
+ const formatTick = (value: number) =>
387
+ props.format ? props.format(value) : compact(value, tickUnit.value);
388
+
389
+ const formatValue = (value: number) =>
390
+ props.format ? props.format(value) : compact(value, unitFor(value));
391
+
80
392
  const parseHex = (hex: string) => {
81
- let h = hex.replace("#", "").trim();
82
- if (h.length === 3)
83
- h = h
84
- .split("")
85
- .map((c) => c + c)
86
- .join("");
87
- const n = parseInt(h, 16);
88
- return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
393
+ let digits = hex.replace("#", "");
394
+ if (digits.length === 3)
395
+ digits = [...digits].map((digit) => digit + digit).join("");
396
+
397
+ const n = parseInt(digits, 16);
398
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
89
399
  };
90
400
 
91
- const toHex = (r: number, g: number, b: number) => {
92
- const channel = (v: number) =>
93
- Math.max(0, Math.min(255, Math.round(v)))
401
+ const mix = (from: string, to: string, amount: number) => {
402
+ const a = parseHex(from);
403
+ const b = parseHex(to);
404
+
405
+ const channels = a.map((channel, index) =>
406
+ Math.round(channel + (b[index]! - channel) * amount)
94
407
  .toString(16)
95
- .padStart(2, "0");
96
- return `#${channel(r)}${channel(g)}${channel(b)}`;
408
+ .padStart(2, "0"),
409
+ );
410
+
411
+ return `#${channels.join("")}`;
97
412
  };
98
413
 
99
- const mix = (a: string, b: string, t: number) => {
100
- const from = parseHex(a);
101
- const to = parseHex(b);
102
- return toHex(
103
- from.r + (to.r - from.r) * t,
104
- from.g + (to.g - from.g) * t,
105
- from.b + (to.b - from.b) * t,
106
- );
414
+ const colorsOf = (index: number) => {
415
+ const entry = series.value[index];
416
+
417
+ return {
418
+ color: entry?.color ?? (index === 0 ? props.color : undefined),
419
+ baseColor:
420
+ entry?.baseColor ?? (index === 0 ? props.baseColor : undefined),
421
+ };
107
422
  };
108
423
 
109
- const peak = computed(() => props.color ?? "#000000");
110
- const base = computed(
111
- () =>
112
- props.baseColor ??
113
- (props.color ? mix(props.color, "#000000", 0.4) : "#000000"),
424
+ const colorKey = computed(() =>
425
+ series.value.map((_, index) => Object.values(colorsOf(index))).join(),
114
426
  );
115
427
 
116
- const paintForDepth = (depth: number): Paint => {
117
- if (!props.color) {
118
- const index = depth <= 0 ? 0 : depth > FADE_ROWS ? 3 : depth;
119
- return { cls: DEFAULT_CLASSES[index] };
120
- }
121
- if (depth <= 0) return { fill: peak.value };
122
- if (depth > FADE_ROWS) return { fill: base.value };
123
- return { fill: mix(peak.value, base.value, depth / (FADE_ROWS + 1)) };
428
+ const isSingleSeries = computed(() => series.value.length === 1);
429
+
430
+ const fadedPaint = (depth: number): Paint => {
431
+ const { color, baseColor } = colorsOf(0);
432
+
433
+ if (!color)
434
+ return { className: depth > FADE_ROWS ? PALETTE[0]!.fill : FADE[depth] };
435
+
436
+ const blendable = HEX.test(color) && (!baseColor || HEX.test(baseColor));
437
+ const body = baseColor ?? (blendable ? mix(color, "#000000", 0.4) : color);
438
+
439
+ if (depth <= 0) return { fill: color };
440
+ if (depth > FADE_ROWS || !blendable) return { fill: body };
441
+ return { fill: mix(color, body, depth / (FADE_ROWS + 1)) };
124
442
  };
125
443
 
126
- const basePaint = (): Paint =>
127
- props.color ? { fill: base.value } : { cls: DEFAULT_CLASSES[3] };
444
+ const seriesPaint = (index: number): Paint => {
445
+ const { color } = colorsOf(index);
446
+ if (color) return { fill: color };
447
+ return { className: PALETTE[index % PALETTE.length]!.fill };
448
+ };
128
449
 
129
- const rows = computed(() => Math.max(1, Math.floor(props.rows)));
450
+ const swatchOf = (index: number) => {
451
+ const { color } = colorsOf(index);
452
+ if (color) return { swatchStyle: { backgroundColor: color } };
453
+ return { swatchClass: PALETTE[index % PALETTE.length]!.swatch };
454
+ };
130
455
 
131
- const series = computed<UixyChartSeries[]>(() => {
132
- if (props.series?.length) return props.series;
133
- if (props.data?.length)
134
- return [{ label: "data", values: props.data.map((d) => d.value) }];
135
- return [];
456
+ const stacks = computed<Stack[]>(() =>
457
+ Array.from({ length: columnCount.value }, (_, col) => {
458
+ const segments: Segment[] = [];
459
+ let total = 0;
460
+ let height = 0;
461
+
462
+ series.value.forEach((entry, index) => {
463
+ const value = valueAt(entry, col);
464
+ total += value;
465
+
466
+ let end = Math.round((total / yScale.value.max) * rows.value);
467
+ if (value > 0 && end <= height) end = height + 1;
468
+ end = Math.min(rows.value, end);
469
+
470
+ if (end > height) segments.push({ series: index, start: height, end });
471
+ height = end;
472
+ });
473
+
474
+ return { segments, height };
475
+ }),
476
+ );
477
+
478
+ const yAxisWidth = computed(() => {
479
+ if (!props.axis) return 0;
480
+
481
+ const longest = Math.max(
482
+ ...yTickValues.value.map((value) => formatTick(value).length),
483
+ );
484
+
485
+ return Math.ceil(longest * CHAR_WIDTH) + Y_LABEL_GAP;
136
486
  });
137
487
 
138
- const columnCount = computed(() =>
139
- Math.max(0, ...series.value.map((s) => s.values.length)),
488
+ const xAxisHeight = computed(() => (props.axis ? X_AXIS_HEIGHT : 0));
489
+
490
+ const fitCells = (space: number, count: number) =>
491
+ Math.floor((space - (count - 1) * GAP) / count);
492
+
493
+ const plotWidth = computed(() =>
494
+ Math.max(0, chartWidth.value - yAxisWidth.value),
140
495
  );
141
496
 
142
- const columnTotals = computed(() =>
143
- Array.from({ length: columnCount.value }, (_, c) =>
144
- series.value.reduce((sum, s) => sum + Math.max(0, s.values[c] ?? 0), 0),
145
- ),
497
+ const naturalHeight = computed(() => {
498
+ if (!columnCount.value) return 0;
499
+
500
+ const size = Math.max(1, fitCells(plotWidth.value, columnCount.value));
501
+ return rows.value * size + (rows.value - 1) * GAP + xAxisHeight.value;
502
+ });
503
+
504
+ const chartHeight = computed(() =>
505
+ heightFromParent.value ? parentHeight.value : naturalHeight.value,
146
506
  );
147
507
 
148
- const usableRows = computed(() =>
149
- Math.max(1, Math.floor(rows.value * (1 - TOP_HEADROOM))),
508
+ const plotHeight = computed(() =>
509
+ Math.max(0, chartHeight.value - xAxisHeight.value),
150
510
  );
151
511
 
152
- const scale = computed(
153
- () => usableRows.value / Math.max(1, Math.max(0, ...columnTotals.value)),
512
+ const ready = computed(
513
+ () =>
514
+ chartWidth.value > 0 && chartHeight.value > 0 && columnCount.value > 0,
154
515
  );
155
516
 
156
- const barHeights = computed(() =>
157
- columnTotals.value.map((total) =>
158
- Math.min(usableRows.value, Math.round(total * scale.value)),
159
- ),
517
+ const cellSize = computed(() => {
518
+ if (!columnCount.value) return 0;
519
+
520
+ return Math.max(
521
+ 1,
522
+ Math.min(
523
+ fitCells(plotWidth.value, columnCount.value),
524
+ fitCells(plotHeight.value, rows.value),
525
+ ),
526
+ );
527
+ });
528
+
529
+ const spanOf = (count: number) => count * cellSize.value + (count - 1) * GAP;
530
+
531
+ const gridWidth = computed(() => spanOf(columnCount.value));
532
+ const gridHeight = computed(() => spanOf(rows.value));
533
+
534
+ const gridX = computed(
535
+ () =>
536
+ yAxisWidth.value +
537
+ Math.max(0, Math.round((plotWidth.value - gridWidth.value) / 2)),
160
538
  );
161
539
 
162
- const vbWidth = computed(
163
- () => columnCount.value * CELL + Math.max(0, columnCount.value - 1) * GAP,
540
+ const gridY = computed(() =>
541
+ Math.max(0, plotHeight.value - gridHeight.value),
164
542
  );
165
- const vbHeight = computed(() => rows.value * CELL + (rows.value - 1) * GAP);
166
543
 
167
- type Scheduled = {
168
- apply: () => void;
169
- done: boolean;
170
- timer?: ReturnType<typeof setTimeout>;
544
+ const gridBottom = computed(() => gridY.value + gridHeight.value);
545
+
546
+ const cellX = (col: number) => gridX.value + col * (cellSize.value + GAP);
547
+
548
+ const cellY = (row: number) =>
549
+ gridBottom.value - (row + 1) * cellSize.value - row * GAP;
550
+
551
+ const columnCenter = (col: number) => cellX(col) + cellSize.value / 2;
552
+
553
+ const stackTop = (col: number) => {
554
+ const height = stacks.value[col]?.height ?? 0;
555
+ return height ? cellY(height - 1) : gridBottom.value;
171
556
  };
172
557
 
173
- let prevHeights: number[] = [];
174
- let prevPaint: Record<string, Paint> = {};
175
- let scheduled: Scheduled[] = [];
558
+ const yLabelX = computed(() => gridX.value - Y_LABEL_GAP);
559
+ const yDotX = computed(() => gridX.value - Y_DOT_GAP);
560
+ const xLabelY = computed(() => gridBottom.value + X_LABEL_OFFSET);
561
+
562
+ const yTicks = computed(() => {
563
+ const spacing = gridHeight.value / yScale.value.steps;
564
+ const every = Math.max(
565
+ 1,
566
+ Math.ceil(Y_TICK_MIN_SPACING / Math.max(1, spacing)),
567
+ );
568
+
569
+ return yTickValues.value
570
+ .filter((_, index) => index % every === 0)
571
+ .map((value) => ({
572
+ value,
573
+ label: formatTick(value),
574
+ y: gridBottom.value - (value / yScale.value.max) * gridHeight.value,
575
+ }));
576
+ });
577
+
578
+ const labelRuns = computed<LabelRun[]>(() => {
579
+ const source = props.axisLabels?.length ? props.axisLabels : labels.value;
580
+ const count = columnCount.value;
176
581
 
177
- const renderCells = ref<Cell[]>([]);
582
+ if (!source.length || !count) return [];
178
583
 
179
- const flushScheduled = () => {
180
- scheduled.forEach((s) => {
181
- if (s.timer) clearTimeout(s.timer);
182
- if (!s.done) s.apply();
584
+ if (source.length !== count)
585
+ return source
586
+ .map((label, index) => ({
587
+ label,
588
+ start: Math.round((index * count) / source.length),
589
+ end: Math.round(((index + 1) * count) / source.length),
590
+ }))
591
+ .filter((run) => run.end > run.start);
592
+
593
+ const runs: LabelRun[] = [];
594
+
595
+ source.forEach((label, index) => {
596
+ const previous = runs[runs.length - 1];
597
+ if (previous?.label === label) previous.end = index + 1;
598
+ else runs.push({ label, start: index, end: index + 1 });
183
599
  });
184
- scheduled = [];
600
+
601
+ return runs;
602
+ });
603
+
604
+ const xTicks = computed(() => {
605
+ const runs = labelRuns.value;
606
+ if (!runs.length) return [];
607
+
608
+ const widest = Math.max(
609
+ ...runs.map((run) => run.label.length * CHAR_WIDTH + X_LABEL_PADDING),
610
+ );
611
+ const every = Math.max(
612
+ 1,
613
+ Math.ceil(widest / Math.max(1, gridWidth.value / runs.length)),
614
+ );
615
+ const visible = runs.filter((_, index) => index % every === 0);
616
+ const active = activeColumn.value;
617
+
618
+ return visible.map((run, index) => {
619
+ const until = visible[index + 1]?.start ?? columnCount.value;
620
+
621
+ return {
622
+ label: run.label,
623
+ start: run.start,
624
+ x: (columnCenter(run.start) + columnCenter(run.end - 1)) / 2,
625
+ active: active !== null && active >= run.start && active < until,
626
+ };
627
+ });
628
+ });
629
+
630
+ const xSeparators = computed(() =>
631
+ xTicks.value
632
+ .slice(1)
633
+ .map((tick, index) => (xTicks.value[index]!.x + tick.x) / 2),
634
+ );
635
+
636
+ const activeColumn = computed(() => {
637
+ if (!props.interactive) return null;
638
+
639
+ const col = hoveredColumn.value ?? focusedColumn.value;
640
+ return col !== null && col < columnCount.value ? col : null;
641
+ });
642
+
643
+ const crosshair = computed<Point | null>(() => {
644
+ const col = activeColumn.value;
645
+ if (col === null || !ready.value) return null;
646
+
647
+ return { x: columnCenter(col), y: stackTop(col) };
648
+ });
649
+
650
+ const tooltipTitle = computed(() => {
651
+ const col = activeColumn.value;
652
+ if (col === null) return "";
653
+
654
+ return labels.value[col] ?? `#${col + 1}`;
655
+ });
656
+
657
+ const tooltipRows = computed(() => {
658
+ const col = activeColumn.value;
659
+ if (col === null) return [];
660
+
661
+ return series.value.map((entry, index) => ({
662
+ label: entry.label,
663
+ value: formatValue(valueAt(entry, col)),
664
+ ...swatchOf(index),
665
+ }));
666
+ });
667
+
668
+ const announcement = computed(() => {
669
+ if (focusedColumn.value === null || !tooltipRows.value.length) return "";
670
+
671
+ const values = tooltipRows.value
672
+ .map((row) => `${row.label} ${row.value}`)
673
+ .join(", ");
674
+
675
+ return `${tooltipTitle.value}: ${values}`;
676
+ });
677
+
678
+ const ariaLabel = computed(() => {
679
+ if (!series.value.length) return "Chart";
680
+
681
+ const names = series.value.map((entry) => entry.label).join(", ");
682
+
683
+ return [
684
+ `Chart with ${columnCount.value} columns`,
685
+ `series: ${names}`,
686
+ `peak ${formatValue(maxTotal.value)}`,
687
+ ].join(", ");
688
+ });
689
+
690
+ const revealTransition = computed(() =>
691
+ props.animate ? `opacity ${REVEAL_DURATION}s ease-out` : "none",
692
+ );
693
+
694
+ const followStyle = computed(() => ({
695
+ transform: `translateX(${crosshairAt.value.x}px)`,
696
+ opacity: crosshair.value ? 1 : 0,
697
+ transition: instant.value
698
+ ? FADE_TRANSITION
699
+ : `${MOVE_TRANSITION}, ${FADE_TRANSITION}`,
700
+ }));
701
+
702
+ const markerStyle = computed(() => ({
703
+ transform: `translateY(${crosshairAt.value.y}px)`,
704
+ transition: instant.value ? "none" : MOVE_TRANSITION,
705
+ }));
706
+
707
+ const tooltipStyle = computed(() => ({
708
+ transform: `translate3d(${tooltipAt.value.x}px, ${tooltipAt.value.y}px, 0)`,
709
+ scale: crosshair.value ? 1 : 0.96,
710
+ opacity: crosshair.value ? 1 : 0,
711
+ transition: instant.value
712
+ ? `${FADE_TRANSITION}, ${SCALE_TRANSITION}`
713
+ : `${MOVE_TRANSITION}, ${FADE_TRANSITION}, ${SCALE_TRANSITION}`,
714
+ }));
715
+
716
+ const placeTooltip = ({ x, y }: Point) => {
717
+ const size = tooltipSize.value;
718
+ const right = x + TOOLTIP_OFFSET + size.width;
719
+ const flipped = right > chartWidth.value - TOOLTIP_MARGIN;
720
+
721
+ return {
722
+ flipped,
723
+ x: clamp(
724
+ flipped ? x - TOOLTIP_OFFSET - size.width : x + TOOLTIP_OFFSET,
725
+ TOOLTIP_MARGIN,
726
+ chartWidth.value - size.width - TOOLTIP_MARGIN,
727
+ ),
728
+ y: clamp(
729
+ y - TOOLTIP_OFFSET,
730
+ TOOLTIP_MARGIN,
731
+ chartHeight.value - size.height - TOOLTIP_MARGIN,
732
+ ),
733
+ };
185
734
  };
186
735
 
187
- const ensureCells = () => {
188
- const needed = columnCount.value * rows.value;
189
- if (renderCells.value.length === needed) return;
190
-
191
- const cells: Cell[] = [];
192
- for (let col = 0; col < columnCount.value; col++) {
193
- const x = col * (CELL + GAP);
194
- for (let row = 0; row < rows.value; row++) {
195
- const y = vbHeight.value - (row + 1) * CELL - row * GAP;
196
- cells.push({
197
- key: `${col}-${row}`,
198
- x,
199
- y,
200
- filled: false,
201
- cls: DEFAULT_CLASSES[3],
202
- });
203
- }
736
+ let instantFrame = 0;
737
+ let tooltipFlipped = false;
738
+
739
+ const skipTransition = () => {
740
+ instant.value = true;
741
+
742
+ cancelAnimationFrame(instantFrame);
743
+ instantFrame = requestAnimationFrame(() => {
744
+ instantFrame = requestAnimationFrame(() => {
745
+ instant.value = false;
746
+ });
747
+ });
748
+ };
749
+
750
+ watch(
751
+ crosshair,
752
+ (next, previous) => {
753
+ if (!next) return;
754
+
755
+ const { flipped, x, y } = placeTooltip(next);
756
+ if (!previous || flipped !== tooltipFlipped) skipTransition();
757
+
758
+ tooltipFlipped = flipped;
759
+ crosshairAt.value = next;
760
+ tooltipAt.value = { x, y };
761
+ },
762
+ { flush: "sync" },
763
+ );
764
+
765
+ const columnAt = (clientX: number) => {
766
+ const left = wrapperRef.value!.getBoundingClientRect().left;
767
+ const offset = clientX - left - gridX.value - cellSize.value / 2;
768
+
769
+ return clamp(
770
+ Math.round(offset / (cellSize.value + GAP)),
771
+ 0,
772
+ columnCount.value - 1,
773
+ );
774
+ };
775
+
776
+ const nextColumn = (key: string, from: number) => {
777
+ switch (key) {
778
+ case "ArrowRight":
779
+ return from + 1;
780
+ case "ArrowLeft":
781
+ return from - 1;
782
+ case "Home":
783
+ return 0;
784
+ case "End":
785
+ return columnCount.value - 1;
786
+ default:
787
+ return null;
204
788
  }
789
+ };
205
790
 
206
- renderCells.value = cells;
207
- prevHeights = [];
208
- prevPaint = {};
791
+ const handlePointerMove = (event: PointerEvent) => {
792
+ hoveredColumn.value = columnAt(event.clientX);
209
793
  };
210
794
 
211
- type CellTarget = {
212
- cell: Cell;
213
- filled: boolean;
214
- paint: Paint;
215
- waveStep: number;
216
- changed: boolean;
795
+ const handlePointerDown = (event: PointerEvent) => {
796
+ focusedColumn.value = null;
797
+ handlePointerMove(event);
217
798
  };
218
799
 
219
- const build = () => {
220
- flushScheduled();
221
- ensureCells();
800
+ const handlePointerLeave = (event: PointerEvent) => {
801
+ if (event.pointerType !== "touch") hoveredColumn.value = null;
802
+ };
803
+
804
+ const handlePointerCancel = () => {
805
+ hoveredColumn.value = null;
806
+ };
807
+
808
+ const handleOutsidePointerDown = (event: PointerEvent) => {
809
+ if (!wrapperRef.value?.contains(event.target as Node))
810
+ hoveredColumn.value = null;
811
+ };
812
+
813
+ const handleFocus = (event: FocusEvent) => {
814
+ if ((event.target as HTMLElement).matches(":focus-visible"))
815
+ focusedColumn.value ??= 0;
816
+ };
817
+
818
+ const handleBlur = () => {
819
+ focusedColumn.value = null;
820
+ };
821
+
822
+ const handleKeydown = (event: KeyboardEvent) => {
823
+ if (event.key === "Escape") {
824
+ hoveredColumn.value = null;
825
+ focusedColumn.value = null;
826
+ return;
827
+ }
828
+
829
+ const target = nextColumn(event.key, activeColumn.value ?? 0);
830
+ if (target === null) return;
222
831
 
223
- const heights = barHeights.value;
224
- const nextPaint: Record<string, Paint> = {};
225
-
226
- const columns = Array.from({ length: columnCount.value }, (_, col) => {
227
- const height = heights[col] ?? 0;
228
- const prevHeight = prevHeights[col] ?? 0;
229
- const rising = height >= prevHeight;
230
- let columnChanged = height !== prevHeight;
231
-
232
- const targets: CellTarget[] = [];
233
- for (let row = 0; row < rows.value; row++) {
234
- const cell = renderCells.value[col * rows.value + row]!;
235
- const filled = row < height;
236
- const paint = filled
237
- ? paintForDepth(height - 1 - row)
238
- : (prevPaint[cell.key] ?? basePaint());
239
- nextPaint[cell.key] = paint;
240
-
241
- const changed =
242
- cell.filled !== filled ||
243
- cell.fill !== paint.fill ||
244
- cell.cls !== paint.cls;
245
- if (changed) columnChanged = true;
246
-
247
- targets.push({
248
- cell,
249
- filled,
250
- paint,
251
- waveStep: rising ? row : rows.value - 1 - row,
252
- changed,
253
- });
254
- }
255
-
256
- return { columnChanged, targets };
832
+ event.preventDefault();
833
+ hoveredColumn.value = null;
834
+ focusedColumn.value = clamp(target, 0, columnCount.value - 1);
835
+ };
836
+
837
+ const batches = new Map<number, Batch>();
838
+ let cellsShape = "";
839
+
840
+ const schedule = (delay: number, task: () => void) => {
841
+ const batch = batches.get(delay);
842
+
843
+ if (batch) {
844
+ batch.tasks.push(task);
845
+ return;
846
+ }
847
+
848
+ batches.set(delay, {
849
+ tasks: [task],
850
+ timer: setTimeout(() => {
851
+ batches.get(delay)?.tasks.forEach((run) => run());
852
+ batches.delete(delay);
853
+ }, delay),
257
854
  });
855
+ };
258
856
 
259
- let activeColumn = 0;
260
- columns.forEach(({ columnChanged, targets }) => {
261
- const columnDelay = activeColumn * COLUMN_STEP;
262
- if (columnChanged) activeColumn++;
263
-
264
- targets.forEach(({ cell, filled, paint, waveStep, changed }) => {
265
- if (!changed) return;
266
-
267
- const apply = () => {
268
- cell.filled = filled;
269
- cell.fill = paint.fill;
270
- cell.cls = paint.cls;
271
- };
272
-
273
- const delay = props.animate ? columnDelay + waveStep * ROW_STEP : 0;
274
- if (delay <= 0) {
275
- apply();
276
- return;
277
- }
278
-
279
- const record: Scheduled = { apply, done: false };
280
- record.timer = setTimeout(() => {
281
- record.done = true;
282
- apply();
283
- }, delay * 1000);
284
- scheduled.push(record);
857
+ const cancelBatches = () => {
858
+ batches.forEach(({ timer }) => clearTimeout(timer));
859
+ batches.clear();
860
+ };
861
+
862
+ const flushBatches = () => {
863
+ const tasks = [...batches.values()].flatMap((batch) => batch.tasks);
864
+
865
+ cancelBatches();
866
+ tasks.forEach((run) => run());
867
+ };
868
+
869
+ const ensureCells = () => {
870
+ const shape = `${columnCount.value}x${rows.value}`;
871
+ if (shape === cellsShape) return;
872
+ cellsShape = shape;
873
+
874
+ cells.value = Array.from({ length: columnCount.value }, (_, col) =>
875
+ Array.from({ length: rows.value }, (_, row) => ({
876
+ key: `${col}-${row}`,
877
+ col,
878
+ row,
879
+ filled: false,
880
+ className: PALETTE[0]!.fill,
881
+ })),
882
+ ).flat();
883
+ };
884
+
885
+ const targetPaint = (segment: Segment, row: number, height: number) =>
886
+ isSingleSeries.value
887
+ ? fadedPaint(height - 1 - row)
888
+ : seriesPaint(segment.series);
889
+
890
+ const syncCells = () => {
891
+ flushBatches();
892
+ ensureCells();
893
+
894
+ if (!ready.value) return;
895
+
896
+ let wave = 0;
897
+
898
+ stacks.value.forEach(({ segments, height }, col) => {
899
+ const firstCell = col * rows.value;
900
+ const column = cells.value.slice(firstCell, firstCell + rows.value);
901
+ const rising = height >= column.filter((cell) => cell.filled).length;
902
+ const columnDelay = wave * COLUMN_STEP;
903
+ let changed = false;
904
+
905
+ column.forEach((cell, row) => {
906
+ const segment = segments.find(
907
+ ({ start, end }) => row >= start && row < end,
908
+ );
909
+ const filled = !!segment;
910
+ const { fill, className } = segment
911
+ ? targetPaint(segment, row, height)
912
+ : cell;
913
+
914
+ const unchanged =
915
+ filled === cell.filled &&
916
+ fill === cell.fill &&
917
+ className === cell.className;
918
+
919
+ if (unchanged) return;
920
+
921
+ changed = true;
922
+
923
+ const apply = () => Object.assign(cell, { filled, fill, className });
924
+ const step = rising ? row : rows.value - 1 - row;
925
+ const delay = props.animate
926
+ ? Math.round((columnDelay + step * ROW_STEP) * 1000)
927
+ : 0;
928
+
929
+ if (delay > 0) schedule(delay, apply);
930
+ else apply();
285
931
  });
932
+
933
+ if (changed) wave++;
286
934
  });
935
+ };
287
936
 
288
- prevHeights = heights.slice();
289
- prevPaint = nextPaint;
937
+ watch([stacks, colorKey, () => props.animate, ready], syncCells, {
938
+ immediate: true,
939
+ flush: "post",
940
+ });
941
+
942
+ let observer: ResizeObserver | null = null;
943
+
944
+ const measure = () => {
945
+ const wrapper = wrapperRef.value;
946
+ const tooltip = tooltipRef.value;
947
+
948
+ if (wrapper) {
949
+ const rect = wrapper.getBoundingClientRect();
950
+
951
+ if (heightFromParent.value === null && rect.width > 0)
952
+ heightFromParent.value = rect.height > 0;
953
+
954
+ chartWidth.value = rect.width;
955
+ parentHeight.value = rect.height;
956
+ }
957
+
958
+ if (tooltip) {
959
+ tooltipSize.value = {
960
+ width: tooltip.offsetWidth,
961
+ height: tooltip.offsetHeight,
962
+ };
963
+ }
290
964
  };
291
965
 
292
- watch(
293
- [
294
- barHeights,
295
- () => props.color,
296
- () => props.baseColor,
297
- () => props.animate,
298
- rows,
299
- columnCount,
300
- ],
301
- build,
302
- { immediate: true },
303
- );
966
+ onMounted(() => {
967
+ observer = new ResizeObserver(measure);
968
+
969
+ if (wrapperRef.value) observer.observe(wrapperRef.value);
970
+ if (tooltipRef.value) observer.observe(tooltipRef.value);
971
+
972
+ document.addEventListener("pointerdown", handleOutsidePointerDown);
973
+ });
304
974
 
305
975
  onUnmounted(() => {
306
- scheduled.forEach((s) => s.timer && clearTimeout(s.timer));
976
+ observer?.disconnect();
977
+ document.removeEventListener("pointerdown", handleOutsidePointerDown);
978
+ cancelAnimationFrame(instantFrame);
979
+ cancelBatches();
307
980
  });
308
981
  </script>