@velkymx/vibeui 0.9.0 → 1.1.0
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.
- package/AGENTS.md +44 -0
- package/CLAUDE.md +48 -0
- package/README.md +222 -197
- package/dist/App.vue.d.ts +2 -1
- package/dist/components/HelloWorld.vue.d.ts +2 -1
- package/dist/components/VibeAccordion.vue.d.ts +39 -40
- package/dist/components/VibeAlert.vue.d.ts +21 -37
- package/dist/components/VibeAutocomplete.vue.d.ts +59 -31
- package/dist/components/VibeBadge.vue.d.ts +10 -18
- package/dist/components/VibeBreadcrumb.vue.d.ts +25 -21
- package/dist/components/VibeButton.vue.d.ts +13 -17
- package/dist/components/VibeButtonGroup.vue.d.ts +11 -19
- package/dist/components/VibeCard.vue.d.ts +51 -24
- package/dist/components/VibeCarousel.vue.d.ts +27 -49
- package/dist/components/VibeChartBar.vue.d.ts +61 -0
- package/dist/components/VibeChartLine.vue.d.ts +70 -0
- package/dist/components/VibeChartPie.vue.d.ts +34 -0
- package/dist/components/VibeCloseButton.vue.d.ts +9 -7
- package/dist/components/VibeCol.vue.d.ts +9 -17
- package/dist/components/VibeCollapse.vue.d.ts +24 -32
- package/dist/components/VibeContainer.vue.d.ts +9 -17
- package/dist/components/VibeDataTable.vue.d.ts +122 -63
- package/dist/components/VibeDatePicker.vue.d.ts +4 -6
- package/dist/components/VibeDraggable.vue.d.ts +9 -13
- package/dist/components/VibeDropdown.vue.d.ts +46 -43
- package/dist/components/VibeDroppable.vue.d.ts +9 -13
- package/dist/components/VibeFileInput.vue.d.ts +14 -20
- package/dist/components/VibeFormCheckbox.vue.d.ts +30 -22
- package/dist/components/VibeFormDatepicker.vue.d.ts +27 -30
- package/dist/components/VibeFormErrorSummary.vue.d.ts +17 -0
- package/dist/components/VibeFormGroup.vue.d.ts +10 -13
- package/dist/components/VibeFormInput.vue.d.ts +60 -25
- package/dist/components/VibeFormRadio.vue.d.ts +23 -24
- package/dist/components/VibeFormSelect.vue.d.ts +28 -34
- package/dist/components/VibeFormSpinbutton.vue.d.ts +31 -34
- package/dist/components/VibeFormSwitch.vue.d.ts +22 -23
- package/dist/components/VibeFormTextarea.vue.d.ts +24 -25
- package/dist/components/VibeFormWysiwyg.vue.d.ts +23 -22
- package/dist/components/VibeHero.vue.d.ts +117 -0
- package/dist/components/VibeIcon.vue.d.ts +27 -7
- package/dist/components/VibeInputGroup.vue.d.ts +12 -14
- package/dist/components/VibeLink.vue.d.ts +14 -18
- package/dist/components/VibeListGroup.vue.d.ts +26 -22
- package/dist/components/VibeModal.vue.d.ts +47 -45
- package/dist/components/VibeNav.vue.d.ts +40 -20
- package/dist/components/VibeNavbar.vue.d.ts +12 -19
- package/dist/components/VibeNavbarBrand.vue.d.ts +9 -17
- package/dist/components/VibeNavbarNav.vue.d.ts +52 -33
- package/dist/components/VibeNavbarToggle.vue.d.ts +7 -5
- package/dist/components/VibeOffcanvas.vue.d.ts +28 -43
- package/dist/components/VibePagination.vue.d.ts +38 -29
- package/dist/components/VibePlaceholder.vue.d.ts +9 -17
- package/dist/components/VibePopover.vue.d.ts +17 -40
- package/dist/components/VibeProgress.vue.d.ts +13 -21
- package/dist/components/VibeResizable.vue.d.ts +10 -14
- package/dist/components/VibeRow.vue.d.ts +9 -17
- package/dist/components/VibeScrollspy.vue.d.ts +37 -25
- package/dist/components/VibeSkeleton.vue.d.ts +2 -1
- package/dist/components/VibeSlider.vue.d.ts +4 -5
- package/dist/components/VibeSortable.vue.d.ts +37 -18
- package/dist/components/VibeSpinner.vue.d.ts +4 -7
- package/dist/components/VibeStepper.vue.d.ts +32 -31
- package/dist/components/VibeTab.vue.d.ts +7 -11
- package/dist/components/VibeTabContent.vue.d.ts +13 -31
- package/dist/components/VibeTabs.vue.d.ts +9 -13
- package/dist/components/VibeToast.vue.d.ts +31 -44
- package/dist/components/VibeToastHost.vue.d.ts +2 -1
- package/dist/components/VibeTooltip.vue.d.ts +16 -39
- package/dist/components/chart/chartColors.d.ts +3 -0
- package/dist/components/chart/chartResize.d.ts +2 -0
- package/dist/components/chart/chartTooltip.d.ts +2 -0
- package/dist/components/chart/chartTypes.d.ts +6 -0
- package/dist/components/chart/drawBar.d.ts +5 -0
- package/dist/components/chart/drawLine.d.ts +18 -0
- package/dist/components/chart/drawPie.d.ts +4 -0
- package/dist/components/dndStore.d.ts +1 -0
- package/dist/components/index.d.ts +6 -1
- package/dist/composables/useBreakpoints.d.ts +1 -0
- package/dist/composables/useColorMode.d.ts +1 -1
- package/dist/composables/useId.d.ts +1 -4
- package/dist/directives/vTooltip.d.ts +2 -3
- package/dist/injectionKeys.d.ts +34 -0
- package/dist/purify.es-eYghtX9j.mjs +735 -0
- package/dist/types.d.ts +23 -2
- package/dist/utils/routeKey.d.ts +11 -0
- package/dist/utils/safeCss.d.ts +16 -0
- package/dist/utils/safeHref.d.ts +7 -0
- package/dist/utils/sanitizeHtml.d.ts +13 -0
- package/dist/vibeui.css +2 -1
- package/dist/vibeui.es.js +8527 -5855
- package/dist/vibeui.umd.js +3 -1
- package/docs/README.md +225 -0
- package/docs/components/advanced/popover.md +125 -0
- package/docs/components/advanced/scrollspy.md +106 -0
- package/docs/components/advanced/tooltip.md +90 -0
- package/docs/components/card/card.md +223 -0
- package/docs/components/charts/chart-bar.md +129 -0
- package/docs/components/charts/chart-line.md +136 -0
- package/docs/components/charts/chart-pie.md +102 -0
- package/docs/components/core/alert.md +94 -0
- package/docs/components/core/badge.md +75 -0
- package/docs/components/core/button-group.md +99 -0
- package/docs/components/core/button.md +145 -0
- package/docs/components/core/close-button.md +82 -0
- package/docs/components/core/link.md +79 -0
- package/docs/components/core/placeholder.md +129 -0
- package/docs/components/core/skeleton.md +40 -0
- package/docs/components/core/spinner.md +103 -0
- package/docs/components/data/datatable.md +437 -0
- package/docs/components/hero/hero.md +65 -0
- package/docs/components/interactive/accordion.md +121 -0
- package/docs/components/interactive/carousel.md +112 -0
- package/docs/components/interactive/collapse.md +106 -0
- package/docs/components/interactive/date-picker.md +78 -0
- package/docs/components/interactive/draggable.md +91 -0
- package/docs/components/interactive/dropdown.md +129 -0
- package/docs/components/interactive/modal.md +151 -0
- package/docs/components/interactive/offcanvas.md +92 -0
- package/docs/components/interactive/resizable.md +73 -0
- package/docs/components/interactive/slider.md +57 -0
- package/docs/components/interactive/sortable.md +52 -0
- package/docs/components/interactive/stepper.md +83 -0
- package/docs/components/interactive/tabs.md +66 -0
- package/docs/components/interactive/toast.md +177 -0
- package/docs/components/layout/col.md +117 -0
- package/docs/components/layout/container.md +53 -0
- package/docs/components/layout/row.md +107 -0
- package/docs/components/list/list-group.md +225 -0
- package/docs/components/navigation/breadcrumb.md +120 -0
- package/docs/components/navigation/nav.md +151 -0
- package/docs/components/navigation/navbar.md +114 -0
- package/docs/components/navigation/pagination.md +163 -0
- package/docs/components/progress/progress.md +188 -0
- package/docs/composables/back-button.md +28 -0
- package/docs/composables/breakpoints.md +54 -0
- package/docs/composables/color-mode.md +179 -0
- package/docs/composables/use-form.md +78 -0
- package/docs/composables/use-position.md +68 -0
- package/docs/composables/use-toast.md +91 -0
- package/docs/directives/v-tooltip.md +58 -0
- package/docs/forms/README.md +87 -0
- package/docs/forms/autocomplete.md +96 -0
- package/docs/forms/file-input.md +97 -0
- package/docs/forms/form-checkbox.md +100 -0
- package/docs/forms/form-datepicker.md +80 -0
- package/docs/forms/form-error-summary.md +96 -0
- package/docs/forms/form-group.md +118 -0
- package/docs/forms/form-input.md +140 -0
- package/docs/forms/form-radio.md +82 -0
- package/docs/forms/form-select.md +98 -0
- package/docs/forms/form-spinbutton.md +94 -0
- package/docs/forms/form-switch.md +75 -0
- package/docs/forms/form-textarea.md +82 -0
- package/docs/forms/form-wysiwyg.md +99 -0
- package/docs/forms/input-group.md +70 -0
- package/docs/forms/validation.md +213 -0
- package/docs/getting-started/starter-template.md +136 -0
- package/docs/superpowers/plans/2026-05-12-vibe-charts.md +1999 -0
- package/docs/superpowers/plans/2026-05-29-e2e-browser-testing.md +615 -0
- package/docs/superpowers/specs/2026-05-12-charting-design.md +185 -0
- package/docs/superpowers/specs/2026-05-29-e2e-browser-testing-design.md +168 -0
- package/docs/utilities/position.md +82 -0
- package/docs/versioning.md +57 -0
- package/examples/README.md +201 -0
- package/examples/album.html +262 -0
- package/examples/blog.html +166 -0
- package/examples/carousel.html +114 -0
- package/examples/checkout.html +275 -0
- package/examples/cover.html +89 -0
- package/examples/dashboard.html +156 -0
- package/examples/index.html +129 -0
- package/examples/jumbotron.html +56 -0
- package/examples/mobile-dashboard.html +138 -0
- package/examples/pricing.html +172 -0
- package/examples/product.html +145 -0
- package/examples/sidebars.html +166 -0
- package/examples/sign-in.html +119 -0
- package/examples/starter.html +314 -0
- package/examples/sticky-footer-navbar.html +70 -0
- package/examples/sticky-footer.html +60 -0
- package/examples/test-simple.html +91 -0
- package/llms.txt +801 -0
- package/package.json +44 -14
- package/dist/types/index.d.ts +0 -6
|
@@ -0,0 +1,1999 @@
|
|
|
1
|
+
# VibeChart Components Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add `VibeChartLine`, `VibeChartBar`, `VibeChartPie` — canvas-based, Bootstrap-themed, responsive via ResizeObserver, no external dependencies.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Three separate Vue 3 components each owning their own draw logic (drawLine/drawBar/drawPie), backed by shared internal utilities under `src/components/chart/` (colors, resize, tooltip). Internal utilities are never exported publicly.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Vue 3, TypeScript strict, Canvas 2D API, ResizeObserver, vitest + @vue/test-utils + happy-dom.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Map
|
|
14
|
+
|
|
15
|
+
### Create
|
|
16
|
+
| File | Responsibility |
|
|
17
|
+
|------|---------------|
|
|
18
|
+
| `src/components/chart/chartTypes.ts` | Internal `TooltipHit` interface |
|
|
19
|
+
| `src/components/chart/chartColors.ts` | Bootstrap CSS var color resolver + slice variant |
|
|
20
|
+
| `src/components/chart/chartResize.ts` | ResizeObserver setup/teardown composable |
|
|
21
|
+
| `src/components/chart/chartTooltip.ts` | Canvas tooltip bind/cleanup |
|
|
22
|
+
| `src/components/chart/drawLine.ts` | `drawLine()` + `hitTestLine()` |
|
|
23
|
+
| `src/components/chart/drawBar.ts` | `drawBar()` + `hitTestBar()` |
|
|
24
|
+
| `src/components/chart/drawPie.ts` | `drawPie()` + `hitTestPie()` |
|
|
25
|
+
| `src/components/VibeChartLine.vue` | Line chart component |
|
|
26
|
+
| `src/components/VibeChartBar.vue` | Bar chart component |
|
|
27
|
+
| `src/components/VibeChartPie.vue` | Pie chart component |
|
|
28
|
+
| `tests/mocks/canvasMock.ts` | Shared canvas + ResizeObserver mocks |
|
|
29
|
+
| `tests/components/chart/chartColors.test.ts` | chartColors unit tests |
|
|
30
|
+
| `tests/components/chart/chartTooltip.test.ts` | chartTooltip unit tests |
|
|
31
|
+
| `tests/components/chart/drawLine.test.ts` | drawLine unit tests |
|
|
32
|
+
| `tests/components/chart/drawBar.test.ts` | drawBar unit tests |
|
|
33
|
+
| `tests/components/chart/drawPie.test.ts` | drawPie unit tests |
|
|
34
|
+
| `tests/components/VibeChartLine.test.ts` | VibeChartLine component tests |
|
|
35
|
+
| `tests/components/VibeChartBar.test.ts` | VibeChartBar component tests |
|
|
36
|
+
| `tests/components/VibeChartPie.test.ts` | VibeChartPie component tests |
|
|
37
|
+
|
|
38
|
+
### Modify
|
|
39
|
+
| File | Change |
|
|
40
|
+
|------|--------|
|
|
41
|
+
| `src/types.ts` | Add `ChartDataset`, `ChartData`, `ChartLegendPosition` |
|
|
42
|
+
| `src/components/index.ts` | Import, export, and register all three chart components |
|
|
43
|
+
| `src/index.ts` | Re-export three components and chart types |
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Task 1: Public types + internal chartTypes
|
|
48
|
+
|
|
49
|
+
**Files:**
|
|
50
|
+
- Modify: `src/types.ts`
|
|
51
|
+
- Create: `src/components/chart/chartTypes.ts`
|
|
52
|
+
|
|
53
|
+
No test — pure type declarations.
|
|
54
|
+
|
|
55
|
+
- [ ] **Step 1: Add to `src/types.ts`** (append after existing types)
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// Chart types
|
|
59
|
+
export interface ChartDataset {
|
|
60
|
+
label: string
|
|
61
|
+
data: number[]
|
|
62
|
+
color?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ChartData {
|
|
66
|
+
labels: string[]
|
|
67
|
+
datasets: ChartDataset[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ChartLegendPosition = 'top' | 'bottom' | 'none'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- [ ] **Step 2: Create `src/components/chart/chartTypes.ts`**
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
export interface TooltipHit {
|
|
77
|
+
datasetIndex: number
|
|
78
|
+
pointIndex: number
|
|
79
|
+
value: number
|
|
80
|
+
label: string
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- [ ] **Step 3: Verify TypeScript compiles**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx vue-tsc --noEmit
|
|
88
|
+
```
|
|
89
|
+
Expected: no errors.
|
|
90
|
+
|
|
91
|
+
- [ ] **Step 4: Commit**
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
git add src/types.ts src/components/chart/chartTypes.ts
|
|
95
|
+
git commit -m "feat(charts): add ChartData, ChartDataset, ChartLegendPosition types and internal TooltipHit"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Task 2: Canvas mock helper
|
|
101
|
+
|
|
102
|
+
**Files:**
|
|
103
|
+
- Create: `tests/mocks/canvasMock.ts`
|
|
104
|
+
|
|
105
|
+
No failing test to write — this is test infrastructure used by all subsequent tasks.
|
|
106
|
+
|
|
107
|
+
- [ ] **Step 1: Create `tests/mocks/canvasMock.ts`**
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { vi } from 'vitest'
|
|
111
|
+
|
|
112
|
+
export function createMockCtx() {
|
|
113
|
+
return {
|
|
114
|
+
clearRect: vi.fn(),
|
|
115
|
+
beginPath: vi.fn(),
|
|
116
|
+
closePath: vi.fn(),
|
|
117
|
+
moveTo: vi.fn(),
|
|
118
|
+
lineTo: vi.fn(),
|
|
119
|
+
bezierCurveTo: vi.fn(),
|
|
120
|
+
arc: vi.fn(),
|
|
121
|
+
stroke: vi.fn(),
|
|
122
|
+
fill: vi.fn(),
|
|
123
|
+
fillRect: vi.fn(),
|
|
124
|
+
roundRect: vi.fn(),
|
|
125
|
+
fillText: vi.fn(),
|
|
126
|
+
measureText: vi.fn(() => ({ width: 50 })),
|
|
127
|
+
save: vi.fn(),
|
|
128
|
+
restore: vi.fn(),
|
|
129
|
+
scale: vi.fn(),
|
|
130
|
+
setLineDash: vi.fn(),
|
|
131
|
+
strokeStyle: '' as string,
|
|
132
|
+
fillStyle: '' as string,
|
|
133
|
+
lineWidth: 1,
|
|
134
|
+
font: '',
|
|
135
|
+
textAlign: 'start' as CanvasTextAlign,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function mockCanvas(overrides = createMockCtx()) {
|
|
140
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
|
141
|
+
overrides as unknown as CanvasRenderingContext2D
|
|
142
|
+
)
|
|
143
|
+
return overrides
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function mockResizeObserver() {
|
|
147
|
+
let callback: ResizeObserverCallback | null = null
|
|
148
|
+
const observe = vi.fn()
|
|
149
|
+
const disconnect = vi.fn()
|
|
150
|
+
vi.stubGlobal(
|
|
151
|
+
'ResizeObserver',
|
|
152
|
+
vi.fn((cb: ResizeObserverCallback) => {
|
|
153
|
+
callback = cb
|
|
154
|
+
return { observe, disconnect }
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
return {
|
|
158
|
+
observe,
|
|
159
|
+
disconnect,
|
|
160
|
+
trigger: (w: number, h: number) =>
|
|
161
|
+
callback?.(
|
|
162
|
+
[{ contentRect: { width: w, height: h } } as ResizeObserverEntry],
|
|
163
|
+
{} as ResizeObserver
|
|
164
|
+
),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- [ ] **Step 2: Commit**
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
git add tests/mocks/canvasMock.ts
|
|
173
|
+
git commit -m "test(charts): add canvas and ResizeObserver mock helpers"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Task 3: chartColors
|
|
179
|
+
|
|
180
|
+
**Files:**
|
|
181
|
+
- Create: `src/components/chart/chartColors.ts`
|
|
182
|
+
- Create: `tests/components/chart/chartColors.test.ts`
|
|
183
|
+
|
|
184
|
+
- [ ] **Step 1: Write failing test**
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
// tests/components/chart/chartColors.test.ts
|
|
188
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
189
|
+
import { resolveColors, resolveSliceColors } from '../../../src/components/chart/chartColors'
|
|
190
|
+
import type { ChartDataset } from '../../../src/types'
|
|
191
|
+
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
vi.restoreAllMocks()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('resolveColors', () => {
|
|
197
|
+
it('uses dataset color when provided', () => {
|
|
198
|
+
const el = document.createElement('div')
|
|
199
|
+
const datasets: ChartDataset[] = [{ label: 'A', data: [1], color: '#ff0000' }]
|
|
200
|
+
expect(resolveColors(datasets, el)[0]).toBe('#ff0000')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('reads trimmed BS var when no color provided', () => {
|
|
204
|
+
const el = document.createElement('div')
|
|
205
|
+
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
|
206
|
+
getPropertyValue: (p: string) => (p === '--bs-primary' ? ' #0d6efd ' : ''),
|
|
207
|
+
} as unknown as CSSStyleDeclaration)
|
|
208
|
+
const datasets: ChartDataset[] = [{ label: 'A', data: [1] }]
|
|
209
|
+
expect(resolveColors(datasets, el)[0]).toBe('#0d6efd')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('cycles vars for 9+ datasets', () => {
|
|
213
|
+
const el = document.createElement('div')
|
|
214
|
+
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
|
215
|
+
getPropertyValue: (p: string) => p,
|
|
216
|
+
} as unknown as CSSStyleDeclaration)
|
|
217
|
+
const datasets: ChartDataset[] = Array.from({ length: 9 }, (_, i) => ({
|
|
218
|
+
label: String(i),
|
|
219
|
+
data: [i],
|
|
220
|
+
}))
|
|
221
|
+
const colors = resolveColors(datasets, el)
|
|
222
|
+
expect(colors[8]).toBe(colors[0])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('mixes explicit and auto colors', () => {
|
|
226
|
+
const el = document.createElement('div')
|
|
227
|
+
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
|
228
|
+
getPropertyValue: () => 'auto',
|
|
229
|
+
} as unknown as CSSStyleDeclaration)
|
|
230
|
+
const datasets: ChartDataset[] = [
|
|
231
|
+
{ label: 'A', data: [1], color: 'red' },
|
|
232
|
+
{ label: 'B', data: [2] },
|
|
233
|
+
]
|
|
234
|
+
const colors = resolveColors(datasets, el)
|
|
235
|
+
expect(colors[0]).toBe('red')
|
|
236
|
+
expect(colors[1]).toBe('auto')
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe('resolveSliceColors', () => {
|
|
241
|
+
it('returns n colors cycling BS vars', () => {
|
|
242
|
+
const el = document.createElement('div')
|
|
243
|
+
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
|
244
|
+
getPropertyValue: (p: string) => p,
|
|
245
|
+
} as unknown as CSSStyleDeclaration)
|
|
246
|
+
const colors = resolveSliceColors(3, el)
|
|
247
|
+
expect(colors).toHaveLength(3)
|
|
248
|
+
expect(colors[0]).not.toBe(colors[1])
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- [ ] **Step 2: Run to verify FAIL**
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
npx vitest run tests/components/chart/chartColors.test.ts
|
|
257
|
+
```
|
|
258
|
+
Expected: `Cannot find module '../../../src/components/chart/chartColors'`
|
|
259
|
+
|
|
260
|
+
- [ ] **Step 3: Create `src/components/chart/chartColors.ts`**
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
import type { ChartDataset } from '../../types'
|
|
264
|
+
|
|
265
|
+
const BS_VARS = [
|
|
266
|
+
'--bs-primary',
|
|
267
|
+
'--bs-success',
|
|
268
|
+
'--bs-danger',
|
|
269
|
+
'--bs-warning',
|
|
270
|
+
'--bs-info',
|
|
271
|
+
'--bs-secondary',
|
|
272
|
+
'--bs-dark',
|
|
273
|
+
'--bs-light',
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
export function resolveColors(datasets: ChartDataset[], el: HTMLElement): string[] {
|
|
277
|
+
const style = getComputedStyle(el)
|
|
278
|
+
return datasets.map(
|
|
279
|
+
(ds, i) => ds.color ?? style.getPropertyValue(BS_VARS[i % BS_VARS.length]).trim()
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function resolveSliceColors(count: number, el: HTMLElement): string[] {
|
|
284
|
+
const style = getComputedStyle(el)
|
|
285
|
+
return Array.from({ length: count }, (_, i) =>
|
|
286
|
+
style.getPropertyValue(BS_VARS[i % BS_VARS.length]).trim()
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- [ ] **Step 4: Run to verify PASS**
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
npx vitest run tests/components/chart/chartColors.test.ts
|
|
295
|
+
```
|
|
296
|
+
Expected: all 5 tests pass.
|
|
297
|
+
|
|
298
|
+
- [ ] **Step 5: Commit**
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
git add src/components/chart/chartColors.ts tests/components/chart/chartColors.test.ts
|
|
302
|
+
git commit -m "feat(charts): add chartColors with BS var cycling and slice variant"
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Task 4: chartResize
|
|
308
|
+
|
|
309
|
+
**Files:**
|
|
310
|
+
- Create: `src/components/chart/chartResize.ts`
|
|
311
|
+
|
|
312
|
+
Tested via component mounts in later tasks. No standalone unit test.
|
|
313
|
+
|
|
314
|
+
- [ ] **Step 1: Create `src/components/chart/chartResize.ts`**
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
import { type Ref, onMounted, onUnmounted } from 'vue'
|
|
318
|
+
|
|
319
|
+
export function useChartResize(
|
|
320
|
+
container: Ref<HTMLElement | null>,
|
|
321
|
+
canvas: Ref<HTMLCanvasElement | null>,
|
|
322
|
+
onResize: (w: number, h: number) => void
|
|
323
|
+
): void {
|
|
324
|
+
let observer: ResizeObserver | null = null
|
|
325
|
+
|
|
326
|
+
onMounted(() => {
|
|
327
|
+
observer = new ResizeObserver((entries) => {
|
|
328
|
+
const entry = entries[0]
|
|
329
|
+
if (!entry) return
|
|
330
|
+
const { width, height } = entry.contentRect
|
|
331
|
+
onResize(width, height)
|
|
332
|
+
})
|
|
333
|
+
observer.observe(container.value!)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
onUnmounted(() => {
|
|
337
|
+
observer?.disconnect()
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
- [ ] **Step 2: Verify TypeScript**
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
npx vue-tsc --noEmit
|
|
346
|
+
```
|
|
347
|
+
Expected: no errors.
|
|
348
|
+
|
|
349
|
+
- [ ] **Step 3: Commit**
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
git add src/components/chart/chartResize.ts
|
|
353
|
+
git commit -m "feat(charts): add useChartResize composable with ResizeObserver"
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Task 5: chartTooltip
|
|
359
|
+
|
|
360
|
+
**Files:**
|
|
361
|
+
- Create: `src/components/chart/chartTooltip.ts`
|
|
362
|
+
- Create: `tests/components/chart/chartTooltip.test.ts`
|
|
363
|
+
|
|
364
|
+
- [ ] **Step 1: Write failing test**
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
// tests/components/chart/chartTooltip.test.ts
|
|
368
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
369
|
+
import { bindTooltip } from '../../../src/components/chart/chartTooltip'
|
|
370
|
+
import { createMockCtx } from '../../mocks/canvasMock'
|
|
371
|
+
import type { TooltipHit } from '../../../src/components/chart/chartTypes'
|
|
372
|
+
|
|
373
|
+
let canvas: HTMLCanvasElement
|
|
374
|
+
let ctx: ReturnType<typeof createMockCtx>
|
|
375
|
+
|
|
376
|
+
beforeEach(() => {
|
|
377
|
+
canvas = document.createElement('canvas')
|
|
378
|
+
canvas.width = 400
|
|
379
|
+
canvas.height = 300
|
|
380
|
+
canvas.getBoundingClientRect = vi.fn(
|
|
381
|
+
() =>
|
|
382
|
+
({
|
|
383
|
+
left: 0,
|
|
384
|
+
top: 0,
|
|
385
|
+
width: 400,
|
|
386
|
+
height: 300,
|
|
387
|
+
right: 400,
|
|
388
|
+
bottom: 300,
|
|
389
|
+
x: 0,
|
|
390
|
+
y: 0,
|
|
391
|
+
toJSON: vi.fn(),
|
|
392
|
+
}) as DOMRect
|
|
393
|
+
)
|
|
394
|
+
ctx = createMockCtx()
|
|
395
|
+
vi.spyOn(canvas, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
afterEach(() => {
|
|
399
|
+
vi.restoreAllMocks()
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
describe('bindTooltip', () => {
|
|
403
|
+
it('returns cleanup function', () => {
|
|
404
|
+
const cleanup = bindTooltip(canvas, () => null, vi.fn())
|
|
405
|
+
expect(typeof cleanup).toBe('function')
|
|
406
|
+
cleanup()
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('calls redraw on mousemove', () => {
|
|
410
|
+
const redraw = vi.fn()
|
|
411
|
+
bindTooltip(canvas, () => null, redraw)
|
|
412
|
+
canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 50, clientY: 50 }))
|
|
413
|
+
expect(redraw).toHaveBeenCalled()
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('calls hitTest on mousemove', () => {
|
|
417
|
+
const hitTest = vi.fn(() => null)
|
|
418
|
+
bindTooltip(canvas, hitTest, vi.fn())
|
|
419
|
+
canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 50, clientY: 50 }))
|
|
420
|
+
expect(hitTest).toHaveBeenCalledWith(50, 50)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('calls redraw on mouseleave', () => {
|
|
424
|
+
const redraw = vi.fn()
|
|
425
|
+
bindTooltip(canvas, () => null, redraw)
|
|
426
|
+
canvas.dispatchEvent(new MouseEvent('mouseleave'))
|
|
427
|
+
expect(redraw).toHaveBeenCalled()
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('cleanup removes all listeners', () => {
|
|
431
|
+
const hitTest = vi.fn(() => null)
|
|
432
|
+
const redraw = vi.fn()
|
|
433
|
+
const cleanup = bindTooltip(canvas, hitTest, redraw)
|
|
434
|
+
cleanup()
|
|
435
|
+
redraw.mockClear()
|
|
436
|
+
hitTest.mockClear()
|
|
437
|
+
canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 50, clientY: 50 }))
|
|
438
|
+
canvas.dispatchEvent(new MouseEvent('mouseleave'))
|
|
439
|
+
expect(hitTest).not.toHaveBeenCalled()
|
|
440
|
+
expect(redraw).not.toHaveBeenCalled()
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('draws tooltip text when hitTest returns a hit', () => {
|
|
444
|
+
const hit: TooltipHit = { datasetIndex: 0, pointIndex: 1, value: 42, label: 'Jan' }
|
|
445
|
+
bindTooltip(canvas, () => hit, vi.fn())
|
|
446
|
+
canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }))
|
|
447
|
+
expect(ctx.fillText).toHaveBeenCalledWith(
|
|
448
|
+
expect.stringContaining('Jan'),
|
|
449
|
+
expect.any(Number),
|
|
450
|
+
expect.any(Number)
|
|
451
|
+
)
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
- [ ] **Step 2: Run to verify FAIL**
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
npx vitest run tests/components/chart/chartTooltip.test.ts
|
|
460
|
+
```
|
|
461
|
+
Expected: `Cannot find module '../../../src/components/chart/chartTooltip'`
|
|
462
|
+
|
|
463
|
+
- [ ] **Step 3: Create `src/components/chart/chartTooltip.ts`**
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
import type { TooltipHit } from './chartTypes'
|
|
467
|
+
|
|
468
|
+
export function bindTooltip(
|
|
469
|
+
canvas: HTMLCanvasElement,
|
|
470
|
+
hitTest: (x: number, y: number) => TooltipHit | null,
|
|
471
|
+
redraw: () => void
|
|
472
|
+
): () => void {
|
|
473
|
+
const onMove = (e: MouseEvent) => {
|
|
474
|
+
const rect = canvas.getBoundingClientRect()
|
|
475
|
+
const x = e.clientX - rect.left
|
|
476
|
+
const y = e.clientY - rect.top
|
|
477
|
+
|
|
478
|
+
redraw()
|
|
479
|
+
|
|
480
|
+
const hit = hitTest(x, y)
|
|
481
|
+
if (!hit) return
|
|
482
|
+
|
|
483
|
+
const ctx = canvas.getContext('2d')!
|
|
484
|
+
const text = `${hit.label}: ${hit.value}`
|
|
485
|
+
ctx.save()
|
|
486
|
+
ctx.font = '12px sans-serif'
|
|
487
|
+
const tw = ctx.measureText(text).width + 12
|
|
488
|
+
const th = 22
|
|
489
|
+
let tx = x + 12
|
|
490
|
+
let ty = y - th - 6
|
|
491
|
+
if (tx + tw > rect.width) tx = rect.width - tw - 4
|
|
492
|
+
if (ty < 0) ty = y + 14
|
|
493
|
+
|
|
494
|
+
ctx.fillStyle = 'rgba(0,0,0,0.75)'
|
|
495
|
+
ctx.beginPath()
|
|
496
|
+
ctx.roundRect(tx, ty, tw, th, 4)
|
|
497
|
+
ctx.fill()
|
|
498
|
+
ctx.fillStyle = '#fff'
|
|
499
|
+
ctx.fillText(text, tx + 6, ty + 15)
|
|
500
|
+
ctx.restore()
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const onLeave = () => redraw()
|
|
504
|
+
|
|
505
|
+
canvas.addEventListener('mousemove', onMove)
|
|
506
|
+
canvas.addEventListener('mouseleave', onLeave)
|
|
507
|
+
|
|
508
|
+
return () => {
|
|
509
|
+
canvas.removeEventListener('mousemove', onMove)
|
|
510
|
+
canvas.removeEventListener('mouseleave', onLeave)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
- [ ] **Step 4: Run to verify PASS**
|
|
516
|
+
|
|
517
|
+
```bash
|
|
518
|
+
npx vitest run tests/components/chart/chartTooltip.test.ts
|
|
519
|
+
```
|
|
520
|
+
Expected: all 6 tests pass.
|
|
521
|
+
|
|
522
|
+
- [ ] **Step 5: Commit**
|
|
523
|
+
|
|
524
|
+
```bash
|
|
525
|
+
git add src/components/chart/chartTooltip.ts tests/components/chart/chartTooltip.test.ts
|
|
526
|
+
git commit -m "feat(charts): add chartTooltip with canvas overlay and cleanup"
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## Task 6: drawLine
|
|
532
|
+
|
|
533
|
+
**Files:**
|
|
534
|
+
- Create: `src/components/chart/drawLine.ts`
|
|
535
|
+
- Create: `tests/components/chart/drawLine.test.ts`
|
|
536
|
+
|
|
537
|
+
- [ ] **Step 1: Write failing test**
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
// tests/components/chart/drawLine.test.ts
|
|
541
|
+
import { describe, it, expect } from 'vitest'
|
|
542
|
+
import { drawLine, hitTestLine } from '../../../src/components/chart/drawLine'
|
|
543
|
+
import { createMockCtx } from '../../mocks/canvasMock'
|
|
544
|
+
import type { ChartData } from '../../../src/types'
|
|
545
|
+
|
|
546
|
+
const DATA: ChartData = {
|
|
547
|
+
labels: ['Jan', 'Feb', 'Mar'],
|
|
548
|
+
datasets: [{ label: 'Sales', data: [10, 20, 15] }],
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const MULTI: ChartData = {
|
|
552
|
+
labels: ['A', 'B', 'C'],
|
|
553
|
+
datasets: [
|
|
554
|
+
{ label: 'S1', data: [10, 20, 15] },
|
|
555
|
+
{ label: 'S2', data: [5, 8, 12] },
|
|
556
|
+
],
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
describe('drawLine', () => {
|
|
560
|
+
it('calls clearRect with full dimensions', () => {
|
|
561
|
+
const ctx = createMockCtx()
|
|
562
|
+
drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], true, true, false, false)
|
|
563
|
+
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 400, 300)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('applies dashed style for grid lines when showGrid=true', () => {
|
|
567
|
+
const ctx = createMockCtx()
|
|
568
|
+
drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], true, true, false, false)
|
|
569
|
+
expect(ctx.setLineDash).toHaveBeenCalledWith([4, 4])
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('skips grid lines when showGrid=false', () => {
|
|
573
|
+
const ctx = createMockCtx()
|
|
574
|
+
drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], true, false, false, false)
|
|
575
|
+
expect(ctx.setLineDash).not.toHaveBeenCalledWith([4, 4])
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('strokes one line per dataset', () => {
|
|
579
|
+
const ctx = createMockCtx()
|
|
580
|
+
drawLine(ctx as unknown as CanvasRenderingContext2D, MULTI, 400, 300, ['blue', 'red'], false, false, false, false)
|
|
581
|
+
// At minimum 2 strokes (one per dataset line); axes off so no extra axis strokes
|
|
582
|
+
expect(ctx.stroke.mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('draws points with arc for each data value', () => {
|
|
586
|
+
const ctx = createMockCtx()
|
|
587
|
+
drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], false, false, false, false)
|
|
588
|
+
// 3 points = 3 arc calls
|
|
589
|
+
expect(ctx.arc.mock.calls.length).toBeGreaterThanOrEqual(3)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('fills area under line when fill=true', () => {
|
|
593
|
+
const ctx = createMockCtx()
|
|
594
|
+
drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], false, false, false, true)
|
|
595
|
+
expect(ctx.fill).toHaveBeenCalled()
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('uses bezierCurveTo when smooth=true', () => {
|
|
599
|
+
const ctx = createMockCtx()
|
|
600
|
+
drawLine(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue'], false, false, true, false)
|
|
601
|
+
expect(ctx.bezierCurveTo).toHaveBeenCalled()
|
|
602
|
+
})
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
describe('hitTestLine', () => {
|
|
606
|
+
it('returns null when coords are far from all points', () => {
|
|
607
|
+
expect(hitTestLine(999, 999, DATA, 400, 300, true)).toBeNull()
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('returns hit when coords are within threshold of a point', () => {
|
|
611
|
+
// With showAxes=true: PAD_LEFT=50, PAD_RIGHT=10, PAD_TOP=10, PAD_BOTTOM=30
|
|
612
|
+
// chartW=340, chartH=260, n=3, xStep=170
|
|
613
|
+
// point[0] x=50, values [10,20,15], min=0,max=20,range=20
|
|
614
|
+
// point[0] y = 10 + 260 - (10/20)*260 = 10 + 260 - 130 = 140
|
|
615
|
+
const hit = hitTestLine(50, 140, DATA, 400, 300, true)
|
|
616
|
+
expect(hit).not.toBeNull()
|
|
617
|
+
expect(hit!.pointIndex).toBe(0)
|
|
618
|
+
expect(hit!.value).toBe(10)
|
|
619
|
+
expect(hit!.label).toBe('Jan')
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('returns correct datasetIndex for second dataset', () => {
|
|
623
|
+
// S2 value at index 0 is 5; with range 0-20, y = 10 + 260 - (5/20)*260 = 205
|
|
624
|
+
const hit = hitTestLine(50, 205, MULTI, 400, 300, true)
|
|
625
|
+
expect(hit?.datasetIndex).toBe(1)
|
|
626
|
+
})
|
|
627
|
+
})
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
- [ ] **Step 2: Run to verify FAIL**
|
|
631
|
+
|
|
632
|
+
```bash
|
|
633
|
+
npx vitest run tests/components/chart/drawLine.test.ts
|
|
634
|
+
```
|
|
635
|
+
Expected: `Cannot find module '../../../src/components/chart/drawLine'`
|
|
636
|
+
|
|
637
|
+
- [ ] **Step 3: Create `src/components/chart/drawLine.ts`**
|
|
638
|
+
|
|
639
|
+
```ts
|
|
640
|
+
import type { ChartData } from '../../types'
|
|
641
|
+
import type { TooltipHit } from './chartTypes'
|
|
642
|
+
|
|
643
|
+
const GRID_LINES = 5
|
|
644
|
+
const POINT_RADIUS = 4
|
|
645
|
+
const HIT_THRESHOLD = 12
|
|
646
|
+
|
|
647
|
+
function getPad(showAxes: boolean) {
|
|
648
|
+
return showAxes
|
|
649
|
+
? { left: 50, right: 10, top: 10, bottom: 30 }
|
|
650
|
+
: { left: 10, right: 10, top: 10, bottom: 10 }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function drawLine(
|
|
654
|
+
ctx: CanvasRenderingContext2D,
|
|
655
|
+
data: ChartData,
|
|
656
|
+
w: number,
|
|
657
|
+
h: number,
|
|
658
|
+
colors: string[],
|
|
659
|
+
showAxes: boolean,
|
|
660
|
+
showGrid: boolean,
|
|
661
|
+
smooth: boolean,
|
|
662
|
+
fill: boolean
|
|
663
|
+
): void {
|
|
664
|
+
const pad = getPad(showAxes)
|
|
665
|
+
const chartW = w - pad.left - pad.right
|
|
666
|
+
const chartH = h - pad.top - pad.bottom
|
|
667
|
+
|
|
668
|
+
const allValues = data.datasets.flatMap((ds) => ds.data)
|
|
669
|
+
const minVal = Math.min(0, ...allValues)
|
|
670
|
+
const maxVal = Math.max(0, ...allValues)
|
|
671
|
+
const range = maxVal - minVal || 1
|
|
672
|
+
const n = data.labels.length
|
|
673
|
+
const xStep = chartW / Math.max(n - 1, 1)
|
|
674
|
+
|
|
675
|
+
const toX = (i: number) => pad.left + i * xStep
|
|
676
|
+
const toY = (v: number) => pad.top + chartH - ((v - minVal) / range) * chartH
|
|
677
|
+
|
|
678
|
+
ctx.clearRect(0, 0, w, h)
|
|
679
|
+
|
|
680
|
+
if (showGrid) {
|
|
681
|
+
ctx.strokeStyle = 'rgba(0,0,0,0.1)'
|
|
682
|
+
ctx.lineWidth = 1
|
|
683
|
+
ctx.setLineDash([4, 4])
|
|
684
|
+
for (let g = 0; g <= GRID_LINES; g++) {
|
|
685
|
+
const gy = pad.top + (chartH / GRID_LINES) * g
|
|
686
|
+
ctx.beginPath()
|
|
687
|
+
ctx.moveTo(pad.left, gy)
|
|
688
|
+
ctx.lineTo(pad.left + chartW, gy)
|
|
689
|
+
ctx.stroke()
|
|
690
|
+
}
|
|
691
|
+
ctx.setLineDash([])
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (showAxes) {
|
|
695
|
+
ctx.save()
|
|
696
|
+
ctx.strokeStyle = '#666'
|
|
697
|
+
ctx.lineWidth = 1
|
|
698
|
+
ctx.beginPath()
|
|
699
|
+
ctx.moveTo(pad.left, pad.top)
|
|
700
|
+
ctx.lineTo(pad.left, pad.top + chartH)
|
|
701
|
+
ctx.stroke()
|
|
702
|
+
ctx.beginPath()
|
|
703
|
+
ctx.moveTo(pad.left, pad.top + chartH)
|
|
704
|
+
ctx.lineTo(pad.left + chartW, pad.top + chartH)
|
|
705
|
+
ctx.stroke()
|
|
706
|
+
|
|
707
|
+
ctx.fillStyle = '#666'
|
|
708
|
+
ctx.font = '11px sans-serif'
|
|
709
|
+
ctx.textAlign = 'center'
|
|
710
|
+
data.labels.forEach((label, i) => ctx.fillText(String(label), toX(i), pad.top + chartH + 18))
|
|
711
|
+
|
|
712
|
+
ctx.textAlign = 'right'
|
|
713
|
+
for (let g = 0; g <= GRID_LINES; g++) {
|
|
714
|
+
const v = minVal + (range / GRID_LINES) * (GRID_LINES - g)
|
|
715
|
+
const gy = pad.top + (chartH / GRID_LINES) * g
|
|
716
|
+
ctx.fillText(v.toFixed(0), pad.left - 5, gy + 4)
|
|
717
|
+
}
|
|
718
|
+
ctx.restore()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
for (const [di, ds] of data.datasets.entries()) {
|
|
722
|
+
const color = colors[di]
|
|
723
|
+
|
|
724
|
+
const tracePath = (withMove: boolean) => {
|
|
725
|
+
if (smooth && ds.data.length > 2) {
|
|
726
|
+
ds.data.forEach((v, i) => {
|
|
727
|
+
const x = toX(i)
|
|
728
|
+
const y = toY(v)
|
|
729
|
+
if (i === 0) {
|
|
730
|
+
if (withMove) ctx.moveTo(x, y)
|
|
731
|
+
} else {
|
|
732
|
+
const cpX = (toX(i - 1) + x) / 2
|
|
733
|
+
ctx.bezierCurveTo(cpX, toY(ds.data[i - 1]), cpX, y, x, y)
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
} else {
|
|
737
|
+
ds.data.forEach((v, i) => {
|
|
738
|
+
i === 0 && withMove ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v))
|
|
739
|
+
})
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (fill && ds.data.length > 0) {
|
|
744
|
+
ctx.beginPath()
|
|
745
|
+
tracePath(true)
|
|
746
|
+
ctx.lineTo(toX(ds.data.length - 1), pad.top + chartH)
|
|
747
|
+
ctx.lineTo(toX(0), pad.top + chartH)
|
|
748
|
+
ctx.closePath()
|
|
749
|
+
ctx.fillStyle = `${color}33`
|
|
750
|
+
ctx.fill()
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
ctx.strokeStyle = color
|
|
754
|
+
ctx.lineWidth = 2
|
|
755
|
+
ctx.beginPath()
|
|
756
|
+
tracePath(true)
|
|
757
|
+
ctx.stroke()
|
|
758
|
+
|
|
759
|
+
ctx.fillStyle = color
|
|
760
|
+
ds.data.forEach((v, i) => {
|
|
761
|
+
ctx.beginPath()
|
|
762
|
+
ctx.arc(toX(i), toY(v), POINT_RADIUS, 0, Math.PI * 2)
|
|
763
|
+
ctx.fill()
|
|
764
|
+
})
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function hitTestLine(
|
|
769
|
+
x: number,
|
|
770
|
+
y: number,
|
|
771
|
+
data: ChartData,
|
|
772
|
+
w: number,
|
|
773
|
+
h: number,
|
|
774
|
+
showAxes: boolean
|
|
775
|
+
): TooltipHit | null {
|
|
776
|
+
const pad = getPad(showAxes)
|
|
777
|
+
const chartW = w - pad.left - pad.right
|
|
778
|
+
const chartH = h - pad.top - pad.bottom
|
|
779
|
+
|
|
780
|
+
const allValues = data.datasets.flatMap((ds) => ds.data)
|
|
781
|
+
const minVal = Math.min(0, ...allValues)
|
|
782
|
+
const maxVal = Math.max(0, ...allValues)
|
|
783
|
+
const range = maxVal - minVal || 1
|
|
784
|
+
const n = data.labels.length
|
|
785
|
+
const xStep = chartW / Math.max(n - 1, 1)
|
|
786
|
+
|
|
787
|
+
const toX = (i: number) => pad.left + i * xStep
|
|
788
|
+
const toY = (v: number) => pad.top + chartH - ((v - minVal) / range) * chartH
|
|
789
|
+
|
|
790
|
+
for (const [di, ds] of data.datasets.entries()) {
|
|
791
|
+
for (const [pi, v] of ds.data.entries()) {
|
|
792
|
+
if (Math.hypot(x - toX(pi), y - toY(v)) <= HIT_THRESHOLD) {
|
|
793
|
+
return { datasetIndex: di, pointIndex: pi, value: v, label: data.labels[pi] }
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return null
|
|
798
|
+
}
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
- [ ] **Step 4: Run to verify PASS**
|
|
802
|
+
|
|
803
|
+
```bash
|
|
804
|
+
npx vitest run tests/components/chart/drawLine.test.ts
|
|
805
|
+
```
|
|
806
|
+
Expected: all 10 tests pass.
|
|
807
|
+
|
|
808
|
+
- [ ] **Step 5: Commit**
|
|
809
|
+
|
|
810
|
+
```bash
|
|
811
|
+
git add src/components/chart/drawLine.ts tests/components/chart/drawLine.test.ts
|
|
812
|
+
git commit -m "feat(charts): add drawLine and hitTestLine with smooth, fill, axes, grid support"
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
---
|
|
816
|
+
|
|
817
|
+
## Task 7: VibeChartLine component
|
|
818
|
+
|
|
819
|
+
**Files:**
|
|
820
|
+
- Create: `src/components/VibeChartLine.vue`
|
|
821
|
+
- Create: `tests/components/VibeChartLine.test.ts`
|
|
822
|
+
|
|
823
|
+
- [ ] **Step 1: Write failing test**
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
// tests/components/VibeChartLine.test.ts
|
|
827
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
828
|
+
import { mount } from '@vue/test-utils'
|
|
829
|
+
import VibeChartLine from '../../src/components/VibeChartLine.vue'
|
|
830
|
+
import { mockCanvas, mockResizeObserver } from '../mocks/canvasMock'
|
|
831
|
+
import type { ChartData } from '../../src/types'
|
|
832
|
+
|
|
833
|
+
const DATA: ChartData = {
|
|
834
|
+
labels: ['Jan', 'Feb', 'Mar'],
|
|
835
|
+
datasets: [
|
|
836
|
+
{ label: 'Revenue', data: [10, 20, 15] },
|
|
837
|
+
{ label: 'Costs', data: [5, 8, 12] },
|
|
838
|
+
],
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
beforeEach(() => {
|
|
842
|
+
mockCanvas()
|
|
843
|
+
mockResizeObserver()
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
afterEach(() => {
|
|
847
|
+
vi.restoreAllMocks()
|
|
848
|
+
vi.unstubAllGlobals()
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
describe('VibeChartLine', () => {
|
|
852
|
+
it('renders a canvas element', () => {
|
|
853
|
+
const wrapper = mount(VibeChartLine, { props: { data: DATA } })
|
|
854
|
+
expect(wrapper.find('canvas').exists()).toBe(true)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it('renders legend at bottom by default', () => {
|
|
858
|
+
const wrapper = mount(VibeChartLine, { props: { data: DATA } })
|
|
859
|
+
expect(wrapper.find('.vibe-chart-legend--bottom').exists()).toBe(true)
|
|
860
|
+
expect(wrapper.find('.vibe-chart-legend--top').exists()).toBe(false)
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
it('renders legend at top when legend="top"', () => {
|
|
864
|
+
const wrapper = mount(VibeChartLine, { props: { data: DATA, legend: 'top' } })
|
|
865
|
+
expect(wrapper.find('.vibe-chart-legend--top').exists()).toBe(true)
|
|
866
|
+
expect(wrapper.find('.vibe-chart-legend--bottom').exists()).toBe(false)
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('hides legend when legend="none"', () => {
|
|
870
|
+
const wrapper = mount(VibeChartLine, { props: { data: DATA, legend: 'none' } })
|
|
871
|
+
expect(wrapper.find('.vibe-chart-legend').exists()).toBe(false)
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it('shows all dataset labels in legend', () => {
|
|
875
|
+
const wrapper = mount(VibeChartLine, { props: { data: DATA } })
|
|
876
|
+
const items = wrapper.findAll('.vibe-chart-legend-item')
|
|
877
|
+
expect(items).toHaveLength(2)
|
|
878
|
+
expect(items[0].text()).toContain('Revenue')
|
|
879
|
+
expect(items[1].text()).toContain('Costs')
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('canvas container uses aspect-ratio style by default', () => {
|
|
883
|
+
const wrapper = mount(VibeChartLine, { props: { data: DATA } })
|
|
884
|
+
const container = wrapper.find('.vibe-chart-canvas-container')
|
|
885
|
+
expect(container.attributes('style')).toContain('aspect-ratio')
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
it('canvas container uses explicit height when height prop given', () => {
|
|
889
|
+
const wrapper = mount(VibeChartLine, { props: { data: DATA, height: 300 } })
|
|
890
|
+
const container = wrapper.find('.vibe-chart-canvas-container')
|
|
891
|
+
expect(container.attributes('style')).toContain('300px')
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
it('calls clearRect when ResizeObserver triggers', () => {
|
|
895
|
+
const ctx = mockCanvas()
|
|
896
|
+
const ro = mockResizeObserver()
|
|
897
|
+
mount(VibeChartLine, { props: { data: DATA } })
|
|
898
|
+
ro.trigger(400, 225)
|
|
899
|
+
expect(ctx.clearRect).toHaveBeenCalled()
|
|
900
|
+
})
|
|
901
|
+
})
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
- [ ] **Step 2: Run to verify FAIL**
|
|
905
|
+
|
|
906
|
+
```bash
|
|
907
|
+
npx vitest run tests/components/VibeChartLine.test.ts
|
|
908
|
+
```
|
|
909
|
+
Expected: `Cannot find module '../../src/components/VibeChartLine.vue'`
|
|
910
|
+
|
|
911
|
+
- [ ] **Step 3: Create `src/components/VibeChartLine.vue`**
|
|
912
|
+
|
|
913
|
+
```vue
|
|
914
|
+
<script setup lang="ts">
|
|
915
|
+
import { ref, computed, watch, onMounted, onUnmounted, type PropType } from 'vue'
|
|
916
|
+
import type { ChartData, ChartLegendPosition } from '../types'
|
|
917
|
+
import { resolveColors } from './chart/chartColors'
|
|
918
|
+
import { useChartResize } from './chart/chartResize'
|
|
919
|
+
import { bindTooltip } from './chart/chartTooltip'
|
|
920
|
+
import { drawLine, hitTestLine } from './chart/drawLine'
|
|
921
|
+
|
|
922
|
+
const props = defineProps({
|
|
923
|
+
data: { type: Object as PropType<ChartData>, required: true },
|
|
924
|
+
legend: { type: String as PropType<ChartLegendPosition>, default: 'bottom' },
|
|
925
|
+
height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
|
|
926
|
+
showAxes: { type: Boolean, default: true },
|
|
927
|
+
showGrid: { type: Boolean, default: true },
|
|
928
|
+
smooth: { type: Boolean, default: false },
|
|
929
|
+
fill: { type: Boolean, default: false },
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
const containerEl = ref<HTMLElement | null>(null)
|
|
933
|
+
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
|
934
|
+
let cleanupTooltip: (() => void) | null = null
|
|
935
|
+
let currentW = 0
|
|
936
|
+
let currentH = 0
|
|
937
|
+
|
|
938
|
+
const canvasContainerStyle = computed(() => {
|
|
939
|
+
if (props.height === 'auto') return { width: '100%', aspectRatio: '16/9' }
|
|
940
|
+
const h = typeof props.height === 'number' ? `${props.height}px` : (props.height as string)
|
|
941
|
+
return { width: '100%', height: h }
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
const resolvedColors = computed(() =>
|
|
945
|
+
containerEl.value ? resolveColors(props.data.datasets, containerEl.value) : []
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
function redraw() {
|
|
949
|
+
const canvas = canvasEl.value!
|
|
950
|
+
const ctx = canvas.getContext('2d')!
|
|
951
|
+
const dpr = window.devicePixelRatio || 1
|
|
952
|
+
canvas.width = currentW * dpr
|
|
953
|
+
canvas.height = currentH * dpr
|
|
954
|
+
ctx.scale(dpr, dpr)
|
|
955
|
+
drawLine(
|
|
956
|
+
ctx,
|
|
957
|
+
props.data,
|
|
958
|
+
currentW,
|
|
959
|
+
currentH,
|
|
960
|
+
resolvedColors.value,
|
|
961
|
+
props.showAxes,
|
|
962
|
+
props.showGrid,
|
|
963
|
+
props.smooth,
|
|
964
|
+
props.fill
|
|
965
|
+
)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
useChartResize(containerEl, canvasEl, (w, h) => {
|
|
969
|
+
currentW = w
|
|
970
|
+
currentH = h
|
|
971
|
+
redraw()
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
onMounted(() => {
|
|
975
|
+
cleanupTooltip = bindTooltip(
|
|
976
|
+
canvasEl.value!,
|
|
977
|
+
(x, y) => hitTestLine(x, y, props.data, currentW, currentH, props.showAxes),
|
|
978
|
+
redraw
|
|
979
|
+
)
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
watch(() => props.data, redraw, { deep: true })
|
|
983
|
+
|
|
984
|
+
onUnmounted(() => {
|
|
985
|
+
cleanupTooltip?.()
|
|
986
|
+
})
|
|
987
|
+
</script>
|
|
988
|
+
|
|
989
|
+
<template>
|
|
990
|
+
<div class="vibe-chart">
|
|
991
|
+
<div v-if="legend === 'top'" class="vibe-chart-legend vibe-chart-legend--top">
|
|
992
|
+
<span v-for="(ds, i) in data.datasets" :key="i" class="vibe-chart-legend-item">
|
|
993
|
+
<span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
|
|
994
|
+
{{ ds.label }}
|
|
995
|
+
</span>
|
|
996
|
+
</div>
|
|
997
|
+
<div ref="containerEl" class="vibe-chart-canvas-container" :style="canvasContainerStyle">
|
|
998
|
+
<canvas ref="canvasEl" style="display: block; width: 100%; height: 100%;" />
|
|
999
|
+
</div>
|
|
1000
|
+
<div v-if="legend === 'bottom'" class="vibe-chart-legend vibe-chart-legend--bottom">
|
|
1001
|
+
<span v-for="(ds, i) in data.datasets" :key="i" class="vibe-chart-legend-item">
|
|
1002
|
+
<span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
|
|
1003
|
+
{{ ds.label }}
|
|
1004
|
+
</span>
|
|
1005
|
+
</div>
|
|
1006
|
+
</div>
|
|
1007
|
+
</template>
|
|
1008
|
+
|
|
1009
|
+
<style scoped>
|
|
1010
|
+
.vibe-chart {
|
|
1011
|
+
width: 100%;
|
|
1012
|
+
}
|
|
1013
|
+
.vibe-chart-legend {
|
|
1014
|
+
display: flex;
|
|
1015
|
+
flex-wrap: wrap;
|
|
1016
|
+
gap: 0.5rem;
|
|
1017
|
+
padding: 0.25rem 0;
|
|
1018
|
+
}
|
|
1019
|
+
.vibe-chart-legend--top {
|
|
1020
|
+
margin-bottom: 0.25rem;
|
|
1021
|
+
}
|
|
1022
|
+
.vibe-chart-legend--bottom {
|
|
1023
|
+
margin-top: 0.25rem;
|
|
1024
|
+
}
|
|
1025
|
+
.vibe-chart-legend-item {
|
|
1026
|
+
display: flex;
|
|
1027
|
+
align-items: center;
|
|
1028
|
+
gap: 0.25rem;
|
|
1029
|
+
font-size: 0.875rem;
|
|
1030
|
+
}
|
|
1031
|
+
.vibe-chart-legend-swatch {
|
|
1032
|
+
display: inline-block;
|
|
1033
|
+
width: 12px;
|
|
1034
|
+
height: 12px;
|
|
1035
|
+
border-radius: 2px;
|
|
1036
|
+
flex-shrink: 0;
|
|
1037
|
+
}
|
|
1038
|
+
</style>
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
- [ ] **Step 4: Run to verify PASS**
|
|
1042
|
+
|
|
1043
|
+
```bash
|
|
1044
|
+
npx vitest run tests/components/VibeChartLine.test.ts
|
|
1045
|
+
```
|
|
1046
|
+
Expected: all 8 tests pass.
|
|
1047
|
+
|
|
1048
|
+
- [ ] **Step 5: Commit**
|
|
1049
|
+
|
|
1050
|
+
```bash
|
|
1051
|
+
git add src/components/VibeChartLine.vue tests/components/VibeChartLine.test.ts
|
|
1052
|
+
git commit -m "feat(charts): add VibeChartLine component"
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
---
|
|
1056
|
+
|
|
1057
|
+
## Task 8: drawBar
|
|
1058
|
+
|
|
1059
|
+
**Files:**
|
|
1060
|
+
- Create: `src/components/chart/drawBar.ts`
|
|
1061
|
+
- Create: `tests/components/chart/drawBar.test.ts`
|
|
1062
|
+
|
|
1063
|
+
- [ ] **Step 1: Write failing test**
|
|
1064
|
+
|
|
1065
|
+
```ts
|
|
1066
|
+
// tests/components/chart/drawBar.test.ts
|
|
1067
|
+
import { describe, it, expect } from 'vitest'
|
|
1068
|
+
import { drawBar, hitTestBar } from '../../../src/components/chart/drawBar'
|
|
1069
|
+
import { createMockCtx } from '../../mocks/canvasMock'
|
|
1070
|
+
import type { ChartData } from '../../../src/types'
|
|
1071
|
+
|
|
1072
|
+
const DATA: ChartData = {
|
|
1073
|
+
labels: ['Q1', 'Q2', 'Q3'],
|
|
1074
|
+
datasets: [
|
|
1075
|
+
{ label: 'A', data: [10, 20, 15] },
|
|
1076
|
+
{ label: 'B', data: [5, 8, 12] },
|
|
1077
|
+
],
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
describe('drawBar', () => {
|
|
1081
|
+
it('calls clearRect with full dimensions', () => {
|
|
1082
|
+
const ctx = createMockCtx()
|
|
1083
|
+
drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], true, true, false)
|
|
1084
|
+
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 400, 300)
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
it('calls fillRect for each bar in grouped mode', () => {
|
|
1088
|
+
const ctx = createMockCtx()
|
|
1089
|
+
drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], false, false, false)
|
|
1090
|
+
// 3 labels × 2 datasets = 6 bars
|
|
1091
|
+
expect(ctx.fillRect.mock.calls.length).toBe(6)
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
it('calls fillRect for each bar in stacked mode', () => {
|
|
1095
|
+
const ctx = createMockCtx()
|
|
1096
|
+
drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], false, false, true)
|
|
1097
|
+
// 3 labels × 2 datasets = 6 bars (same count, different positions)
|
|
1098
|
+
expect(ctx.fillRect.mock.calls.length).toBe(6)
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
it('draws grid lines when showGrid=true', () => {
|
|
1102
|
+
const ctx = createMockCtx()
|
|
1103
|
+
drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], true, true, false)
|
|
1104
|
+
expect(ctx.setLineDash).toHaveBeenCalledWith([4, 4])
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
it('skips grid when showGrid=false', () => {
|
|
1108
|
+
const ctx = createMockCtx()
|
|
1109
|
+
drawBar(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['blue', 'red'], true, false, false)
|
|
1110
|
+
expect(ctx.setLineDash).not.toHaveBeenCalledWith([4, 4])
|
|
1111
|
+
})
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
describe('hitTestBar', () => {
|
|
1115
|
+
it('returns null outside chart area', () => {
|
|
1116
|
+
expect(hitTestBar(0, 0, DATA, 400, 300, true, false)).toBeNull()
|
|
1117
|
+
})
|
|
1118
|
+
|
|
1119
|
+
it('returns null below all bars', () => {
|
|
1120
|
+
// y=299 is at the very bottom (x-axis baseline), no bar there
|
|
1121
|
+
expect(hitTestBar(200, 299, DATA, 400, 300, false, false)).toBeNull()
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
it('returns a hit inside a bar', () => {
|
|
1125
|
+
// showAxes=false: PAD=10 all sides, chartW=380, chartH=280
|
|
1126
|
+
// n=3, groupW=380/3≈126.7, GROUP_PADDING=0.2
|
|
1127
|
+
// barW=(126.7*(1-0.2))/2≈50.7
|
|
1128
|
+
// Group 0 startX=10+0+126.7*0.1=22.7
|
|
1129
|
+
// Dataset 0 bar x=22.7+0=22.7, width=50.7
|
|
1130
|
+
// Dataset 0 value=10, max=20, barH=(10/20)*280=140
|
|
1131
|
+
// Bar rect: x=22.7, y=10+280-140=150, w=50.7, h=140
|
|
1132
|
+
const hit = hitTestBar(30, 200, DATA, 400, 300, false, false)
|
|
1133
|
+
expect(hit).not.toBeNull()
|
|
1134
|
+
expect(hit!.pointIndex).toBe(0)
|
|
1135
|
+
})
|
|
1136
|
+
})
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
- [ ] **Step 2: Run to verify FAIL**
|
|
1140
|
+
|
|
1141
|
+
```bash
|
|
1142
|
+
npx vitest run tests/components/chart/drawBar.test.ts
|
|
1143
|
+
```
|
|
1144
|
+
Expected: `Cannot find module '../../../src/components/chart/drawBar'`
|
|
1145
|
+
|
|
1146
|
+
- [ ] **Step 3: Create `src/components/chart/drawBar.ts`**
|
|
1147
|
+
|
|
1148
|
+
```ts
|
|
1149
|
+
import type { ChartData } from '../../types'
|
|
1150
|
+
import type { TooltipHit } from './chartTypes'
|
|
1151
|
+
|
|
1152
|
+
const GRID_LINES = 5
|
|
1153
|
+
const GROUP_PADDING = 0.2
|
|
1154
|
+
|
|
1155
|
+
function getPad(showAxes: boolean) {
|
|
1156
|
+
return showAxes
|
|
1157
|
+
? { left: 50, right: 10, top: 10, bottom: 30 }
|
|
1158
|
+
: { left: 10, right: 10, top: 10, bottom: 10 }
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function getMaxVal(data: ChartData, stacked: boolean): number {
|
|
1162
|
+
if (stacked) {
|
|
1163
|
+
const colSums = data.labels.map((_, i) =>
|
|
1164
|
+
data.datasets.reduce((s, ds) => s + (ds.data[i] ?? 0), 0)
|
|
1165
|
+
)
|
|
1166
|
+
return Math.max(...colSums, 1)
|
|
1167
|
+
}
|
|
1168
|
+
return Math.max(...data.datasets.flatMap((ds) => ds.data), 1)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export function drawBar(
|
|
1172
|
+
ctx: CanvasRenderingContext2D,
|
|
1173
|
+
data: ChartData,
|
|
1174
|
+
w: number,
|
|
1175
|
+
h: number,
|
|
1176
|
+
colors: string[],
|
|
1177
|
+
showAxes: boolean,
|
|
1178
|
+
showGrid: boolean,
|
|
1179
|
+
stacked: boolean
|
|
1180
|
+
): void {
|
|
1181
|
+
const pad = getPad(showAxes)
|
|
1182
|
+
const chartW = w - pad.left - pad.right
|
|
1183
|
+
const chartH = h - pad.top - pad.bottom
|
|
1184
|
+
const n = data.labels.length
|
|
1185
|
+
const numDatasets = data.datasets.length
|
|
1186
|
+
const maxVal = getMaxVal(data, stacked)
|
|
1187
|
+
const groupW = chartW / n
|
|
1188
|
+
const barW = stacked
|
|
1189
|
+
? groupW * (1 - GROUP_PADDING)
|
|
1190
|
+
: (groupW * (1 - GROUP_PADDING)) / numDatasets
|
|
1191
|
+
|
|
1192
|
+
ctx.clearRect(0, 0, w, h)
|
|
1193
|
+
|
|
1194
|
+
if (showGrid) {
|
|
1195
|
+
ctx.strokeStyle = 'rgba(0,0,0,0.1)'
|
|
1196
|
+
ctx.lineWidth = 1
|
|
1197
|
+
ctx.setLineDash([4, 4])
|
|
1198
|
+
for (let g = 1; g <= GRID_LINES; g++) {
|
|
1199
|
+
const gy = pad.top + (chartH / GRID_LINES) * g
|
|
1200
|
+
ctx.beginPath()
|
|
1201
|
+
ctx.moveTo(pad.left, gy)
|
|
1202
|
+
ctx.lineTo(pad.left + chartW, gy)
|
|
1203
|
+
ctx.stroke()
|
|
1204
|
+
}
|
|
1205
|
+
ctx.setLineDash([])
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (showAxes) {
|
|
1209
|
+
ctx.save()
|
|
1210
|
+
ctx.strokeStyle = '#666'
|
|
1211
|
+
ctx.lineWidth = 1
|
|
1212
|
+
ctx.beginPath()
|
|
1213
|
+
ctx.moveTo(pad.left, pad.top)
|
|
1214
|
+
ctx.lineTo(pad.left, pad.top + chartH)
|
|
1215
|
+
ctx.stroke()
|
|
1216
|
+
ctx.beginPath()
|
|
1217
|
+
ctx.moveTo(pad.left, pad.top + chartH)
|
|
1218
|
+
ctx.lineTo(pad.left + chartW, pad.top + chartH)
|
|
1219
|
+
ctx.stroke()
|
|
1220
|
+
|
|
1221
|
+
ctx.fillStyle = '#666'
|
|
1222
|
+
ctx.font = '11px sans-serif'
|
|
1223
|
+
ctx.textAlign = 'center'
|
|
1224
|
+
data.labels.forEach((label, i) => {
|
|
1225
|
+
ctx.fillText(String(label), pad.left + i * groupW + groupW / 2, pad.top + chartH + 18)
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1228
|
+
ctx.textAlign = 'right'
|
|
1229
|
+
for (let g = 0; g <= GRID_LINES; g++) {
|
|
1230
|
+
const v = (maxVal / GRID_LINES) * (GRID_LINES - g)
|
|
1231
|
+
const gy = pad.top + (chartH / GRID_LINES) * g
|
|
1232
|
+
ctx.fillText(v.toFixed(0), pad.left - 5, gy + 4)
|
|
1233
|
+
}
|
|
1234
|
+
ctx.restore()
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (stacked) {
|
|
1238
|
+
for (let i = 0; i < n; i++) {
|
|
1239
|
+
let yOffset = pad.top + chartH
|
|
1240
|
+
const bx = pad.left + i * groupW + (groupW * GROUP_PADDING) / 2
|
|
1241
|
+
for (const [di, ds] of data.datasets.entries()) {
|
|
1242
|
+
const v = ds.data[i] ?? 0
|
|
1243
|
+
const bh = (v / maxVal) * chartH
|
|
1244
|
+
ctx.fillStyle = colors[di]
|
|
1245
|
+
ctx.fillRect(bx, yOffset - bh, barW, bh)
|
|
1246
|
+
yOffset -= bh
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
} else {
|
|
1250
|
+
for (const [di, ds] of data.datasets.entries()) {
|
|
1251
|
+
ctx.fillStyle = colors[di]
|
|
1252
|
+
for (let i = 0; i < n; i++) {
|
|
1253
|
+
const v = ds.data[i] ?? 0
|
|
1254
|
+
const bh = (v / maxVal) * chartH
|
|
1255
|
+
const bx = pad.left + i * groupW + (groupW * GROUP_PADDING) / 2 + di * barW
|
|
1256
|
+
ctx.fillRect(bx, pad.top + chartH - bh, barW, bh)
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
export function hitTestBar(
|
|
1263
|
+
x: number,
|
|
1264
|
+
y: number,
|
|
1265
|
+
data: ChartData,
|
|
1266
|
+
w: number,
|
|
1267
|
+
h: number,
|
|
1268
|
+
showAxes: boolean,
|
|
1269
|
+
stacked: boolean
|
|
1270
|
+
): TooltipHit | null {
|
|
1271
|
+
const pad = getPad(showAxes)
|
|
1272
|
+
const chartW = w - pad.left - pad.right
|
|
1273
|
+
const chartH = h - pad.top - pad.bottom
|
|
1274
|
+
const n = data.labels.length
|
|
1275
|
+
const numDatasets = data.datasets.length
|
|
1276
|
+
const maxVal = getMaxVal(data, stacked)
|
|
1277
|
+
const groupW = chartW / n
|
|
1278
|
+
const barW = stacked
|
|
1279
|
+
? groupW * (1 - GROUP_PADDING)
|
|
1280
|
+
: (groupW * (1 - GROUP_PADDING)) / numDatasets
|
|
1281
|
+
|
|
1282
|
+
const groupI = Math.floor((x - pad.left) / groupW)
|
|
1283
|
+
if (groupI < 0 || groupI >= n) return null
|
|
1284
|
+
|
|
1285
|
+
const groupStartX = pad.left + groupI * groupW + (groupW * GROUP_PADDING) / 2
|
|
1286
|
+
|
|
1287
|
+
if (stacked) {
|
|
1288
|
+
if (x < groupStartX || x > groupStartX + barW) return null
|
|
1289
|
+
let yOffset = pad.top + chartH
|
|
1290
|
+
for (const [di, ds] of data.datasets.entries()) {
|
|
1291
|
+
const v = ds.data[groupI] ?? 0
|
|
1292
|
+
const bh = (v / maxVal) * chartH
|
|
1293
|
+
const barTop = yOffset - bh
|
|
1294
|
+
if (y >= barTop && y <= yOffset) {
|
|
1295
|
+
return { datasetIndex: di, pointIndex: groupI, value: v, label: data.labels[groupI] }
|
|
1296
|
+
}
|
|
1297
|
+
yOffset -= bh
|
|
1298
|
+
}
|
|
1299
|
+
return null
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
for (let di = 0; di < numDatasets; di++) {
|
|
1303
|
+
const bx = groupStartX + di * barW
|
|
1304
|
+
if (x < bx || x > bx + barW) continue
|
|
1305
|
+
const v = data.datasets[di].data[groupI] ?? 0
|
|
1306
|
+
const bh = (v / maxVal) * chartH
|
|
1307
|
+
const barTop = pad.top + chartH - bh
|
|
1308
|
+
if (y >= barTop && y <= pad.top + chartH) {
|
|
1309
|
+
return {
|
|
1310
|
+
datasetIndex: di,
|
|
1311
|
+
pointIndex: groupI,
|
|
1312
|
+
value: v,
|
|
1313
|
+
label: data.labels[groupI],
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return null
|
|
1318
|
+
}
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
- [ ] **Step 4: Run to verify PASS**
|
|
1322
|
+
|
|
1323
|
+
```bash
|
|
1324
|
+
npx vitest run tests/components/chart/drawBar.test.ts
|
|
1325
|
+
```
|
|
1326
|
+
Expected: all 7 tests pass.
|
|
1327
|
+
|
|
1328
|
+
- [ ] **Step 5: Commit**
|
|
1329
|
+
|
|
1330
|
+
```bash
|
|
1331
|
+
git add src/components/chart/drawBar.ts tests/components/chart/drawBar.test.ts
|
|
1332
|
+
git commit -m "feat(charts): add drawBar and hitTestBar with grouped and stacked support"
|
|
1333
|
+
```
|
|
1334
|
+
|
|
1335
|
+
---
|
|
1336
|
+
|
|
1337
|
+
## Task 9: VibeChartBar component
|
|
1338
|
+
|
|
1339
|
+
**Files:**
|
|
1340
|
+
- Create: `src/components/VibeChartBar.vue`
|
|
1341
|
+
- Create: `tests/components/VibeChartBar.test.ts`
|
|
1342
|
+
|
|
1343
|
+
- [ ] **Step 1: Write failing test**
|
|
1344
|
+
|
|
1345
|
+
```ts
|
|
1346
|
+
// tests/components/VibeChartBar.test.ts
|
|
1347
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
1348
|
+
import { mount } from '@vue/test-utils'
|
|
1349
|
+
import VibeChartBar from '../../src/components/VibeChartBar.vue'
|
|
1350
|
+
import { mockCanvas, mockResizeObserver } from '../mocks/canvasMock'
|
|
1351
|
+
import type { ChartData } from '../../src/types'
|
|
1352
|
+
|
|
1353
|
+
const DATA: ChartData = {
|
|
1354
|
+
labels: ['Q1', 'Q2', 'Q3'],
|
|
1355
|
+
datasets: [
|
|
1356
|
+
{ label: 'Revenue', data: [10, 20, 15] },
|
|
1357
|
+
{ label: 'Costs', data: [5, 8, 12] },
|
|
1358
|
+
],
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
beforeEach(() => {
|
|
1362
|
+
mockCanvas()
|
|
1363
|
+
mockResizeObserver()
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
afterEach(() => {
|
|
1367
|
+
vi.restoreAllMocks()
|
|
1368
|
+
vi.unstubAllGlobals()
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
describe('VibeChartBar', () => {
|
|
1372
|
+
it('renders a canvas element', () => {
|
|
1373
|
+
const wrapper = mount(VibeChartBar, { props: { data: DATA } })
|
|
1374
|
+
expect(wrapper.find('canvas').exists()).toBe(true)
|
|
1375
|
+
})
|
|
1376
|
+
|
|
1377
|
+
it('renders legend at bottom by default', () => {
|
|
1378
|
+
const wrapper = mount(VibeChartBar, { props: { data: DATA } })
|
|
1379
|
+
expect(wrapper.find('.vibe-chart-legend--bottom').exists()).toBe(true)
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1382
|
+
it('renders legend at top when legend="top"', () => {
|
|
1383
|
+
const wrapper = mount(VibeChartBar, { props: { data: DATA, legend: 'top' } })
|
|
1384
|
+
expect(wrapper.find('.vibe-chart-legend--top').exists()).toBe(true)
|
|
1385
|
+
})
|
|
1386
|
+
|
|
1387
|
+
it('hides legend when legend="none"', () => {
|
|
1388
|
+
const wrapper = mount(VibeChartBar, { props: { data: DATA, legend: 'none' } })
|
|
1389
|
+
expect(wrapper.find('.vibe-chart-legend').exists()).toBe(false)
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1392
|
+
it('shows dataset labels in legend', () => {
|
|
1393
|
+
const wrapper = mount(VibeChartBar, { props: { data: DATA } })
|
|
1394
|
+
const items = wrapper.findAll('.vibe-chart-legend-item')
|
|
1395
|
+
expect(items[0].text()).toContain('Revenue')
|
|
1396
|
+
expect(items[1].text()).toContain('Costs')
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
it('calls clearRect when ResizeObserver triggers', () => {
|
|
1400
|
+
const ctx = mockCanvas()
|
|
1401
|
+
const ro = mockResizeObserver()
|
|
1402
|
+
mount(VibeChartBar, { props: { data: DATA } })
|
|
1403
|
+
ro.trigger(400, 225)
|
|
1404
|
+
expect(ctx.clearRect).toHaveBeenCalled()
|
|
1405
|
+
})
|
|
1406
|
+
|
|
1407
|
+
it('applies explicit height when given', () => {
|
|
1408
|
+
const wrapper = mount(VibeChartBar, { props: { data: DATA, height: 400 } })
|
|
1409
|
+
expect(wrapper.find('.vibe-chart-canvas-container').attributes('style')).toContain('400px')
|
|
1410
|
+
})
|
|
1411
|
+
})
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
- [ ] **Step 2: Run to verify FAIL**
|
|
1415
|
+
|
|
1416
|
+
```bash
|
|
1417
|
+
npx vitest run tests/components/VibeChartBar.test.ts
|
|
1418
|
+
```
|
|
1419
|
+
Expected: `Cannot find module '../../src/components/VibeChartBar.vue'`
|
|
1420
|
+
|
|
1421
|
+
- [ ] **Step 3: Create `src/components/VibeChartBar.vue`**
|
|
1422
|
+
|
|
1423
|
+
```vue
|
|
1424
|
+
<script setup lang="ts">
|
|
1425
|
+
import { ref, computed, watch, onMounted, onUnmounted, type PropType } from 'vue'
|
|
1426
|
+
import type { ChartData, ChartLegendPosition } from '../types'
|
|
1427
|
+
import { resolveColors } from './chart/chartColors'
|
|
1428
|
+
import { useChartResize } from './chart/chartResize'
|
|
1429
|
+
import { bindTooltip } from './chart/chartTooltip'
|
|
1430
|
+
import { drawBar, hitTestBar } from './chart/drawBar'
|
|
1431
|
+
|
|
1432
|
+
const props = defineProps({
|
|
1433
|
+
data: { type: Object as PropType<ChartData>, required: true },
|
|
1434
|
+
legend: { type: String as PropType<ChartLegendPosition>, default: 'bottom' },
|
|
1435
|
+
height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
|
|
1436
|
+
showAxes: { type: Boolean, default: true },
|
|
1437
|
+
showGrid: { type: Boolean, default: true },
|
|
1438
|
+
stacked: { type: Boolean, default: false },
|
|
1439
|
+
})
|
|
1440
|
+
|
|
1441
|
+
const containerEl = ref<HTMLElement | null>(null)
|
|
1442
|
+
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
|
1443
|
+
let cleanupTooltip: (() => void) | null = null
|
|
1444
|
+
let currentW = 0
|
|
1445
|
+
let currentH = 0
|
|
1446
|
+
|
|
1447
|
+
const canvasContainerStyle = computed(() => {
|
|
1448
|
+
if (props.height === 'auto') return { width: '100%', aspectRatio: '16/9' }
|
|
1449
|
+
const h = typeof props.height === 'number' ? `${props.height}px` : (props.height as string)
|
|
1450
|
+
return { width: '100%', height: h }
|
|
1451
|
+
})
|
|
1452
|
+
|
|
1453
|
+
const resolvedColors = computed(() =>
|
|
1454
|
+
containerEl.value ? resolveColors(props.data.datasets, containerEl.value) : []
|
|
1455
|
+
)
|
|
1456
|
+
|
|
1457
|
+
function redraw() {
|
|
1458
|
+
const canvas = canvasEl.value!
|
|
1459
|
+
const ctx = canvas.getContext('2d')!
|
|
1460
|
+
const dpr = window.devicePixelRatio || 1
|
|
1461
|
+
canvas.width = currentW * dpr
|
|
1462
|
+
canvas.height = currentH * dpr
|
|
1463
|
+
ctx.scale(dpr, dpr)
|
|
1464
|
+
drawBar(ctx, props.data, currentW, currentH, resolvedColors.value, props.showAxes, props.showGrid, props.stacked)
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
useChartResize(containerEl, canvasEl, (w, h) => {
|
|
1468
|
+
currentW = w
|
|
1469
|
+
currentH = h
|
|
1470
|
+
redraw()
|
|
1471
|
+
})
|
|
1472
|
+
|
|
1473
|
+
onMounted(() => {
|
|
1474
|
+
cleanupTooltip = bindTooltip(
|
|
1475
|
+
canvasEl.value!,
|
|
1476
|
+
(x, y) => hitTestBar(x, y, props.data, currentW, currentH, props.showAxes, props.stacked),
|
|
1477
|
+
redraw
|
|
1478
|
+
)
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
watch(() => props.data, redraw, { deep: true })
|
|
1482
|
+
|
|
1483
|
+
onUnmounted(() => {
|
|
1484
|
+
cleanupTooltip?.()
|
|
1485
|
+
})
|
|
1486
|
+
</script>
|
|
1487
|
+
|
|
1488
|
+
<template>
|
|
1489
|
+
<div class="vibe-chart">
|
|
1490
|
+
<div v-if="legend === 'top'" class="vibe-chart-legend vibe-chart-legend--top">
|
|
1491
|
+
<span v-for="(ds, i) in data.datasets" :key="i" class="vibe-chart-legend-item">
|
|
1492
|
+
<span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
|
|
1493
|
+
{{ ds.label }}
|
|
1494
|
+
</span>
|
|
1495
|
+
</div>
|
|
1496
|
+
<div ref="containerEl" class="vibe-chart-canvas-container" :style="canvasContainerStyle">
|
|
1497
|
+
<canvas ref="canvasEl" style="display: block; width: 100%; height: 100%;" />
|
|
1498
|
+
</div>
|
|
1499
|
+
<div v-if="legend === 'bottom'" class="vibe-chart-legend vibe-chart-legend--bottom">
|
|
1500
|
+
<span v-for="(ds, i) in data.datasets" :key="i" class="vibe-chart-legend-item">
|
|
1501
|
+
<span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
|
|
1502
|
+
{{ ds.label }}
|
|
1503
|
+
</span>
|
|
1504
|
+
</div>
|
|
1505
|
+
</div>
|
|
1506
|
+
</template>
|
|
1507
|
+
|
|
1508
|
+
<style scoped>
|
|
1509
|
+
.vibe-chart { width: 100%; }
|
|
1510
|
+
.vibe-chart-legend {
|
|
1511
|
+
display: flex;
|
|
1512
|
+
flex-wrap: wrap;
|
|
1513
|
+
gap: 0.5rem;
|
|
1514
|
+
padding: 0.25rem 0;
|
|
1515
|
+
}
|
|
1516
|
+
.vibe-chart-legend--top { margin-bottom: 0.25rem; }
|
|
1517
|
+
.vibe-chart-legend--bottom { margin-top: 0.25rem; }
|
|
1518
|
+
.vibe-chart-legend-item {
|
|
1519
|
+
display: flex;
|
|
1520
|
+
align-items: center;
|
|
1521
|
+
gap: 0.25rem;
|
|
1522
|
+
font-size: 0.875rem;
|
|
1523
|
+
}
|
|
1524
|
+
.vibe-chart-legend-swatch {
|
|
1525
|
+
display: inline-block;
|
|
1526
|
+
width: 12px;
|
|
1527
|
+
height: 12px;
|
|
1528
|
+
border-radius: 2px;
|
|
1529
|
+
flex-shrink: 0;
|
|
1530
|
+
}
|
|
1531
|
+
</style>
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
- [ ] **Step 4: Run to verify PASS**
|
|
1535
|
+
|
|
1536
|
+
```bash
|
|
1537
|
+
npx vitest run tests/components/VibeChartBar.test.ts
|
|
1538
|
+
```
|
|
1539
|
+
Expected: all 7 tests pass.
|
|
1540
|
+
|
|
1541
|
+
- [ ] **Step 5: Commit**
|
|
1542
|
+
|
|
1543
|
+
```bash
|
|
1544
|
+
git add src/components/VibeChartBar.vue tests/components/VibeChartBar.test.ts
|
|
1545
|
+
git commit -m "feat(charts): add VibeChartBar component"
|
|
1546
|
+
```
|
|
1547
|
+
|
|
1548
|
+
---
|
|
1549
|
+
|
|
1550
|
+
## Task 10: drawPie
|
|
1551
|
+
|
|
1552
|
+
**Files:**
|
|
1553
|
+
- Create: `src/components/chart/drawPie.ts`
|
|
1554
|
+
- Create: `tests/components/chart/drawPie.test.ts`
|
|
1555
|
+
|
|
1556
|
+
- [ ] **Step 1: Write failing test**
|
|
1557
|
+
|
|
1558
|
+
```ts
|
|
1559
|
+
// tests/components/chart/drawPie.test.ts
|
|
1560
|
+
import { describe, it, expect } from 'vitest'
|
|
1561
|
+
import { drawPie, hitTestPie } from '../../../src/components/chart/drawPie'
|
|
1562
|
+
import { createMockCtx } from '../../mocks/canvasMock'
|
|
1563
|
+
import type { ChartData } from '../../../src/types'
|
|
1564
|
+
|
|
1565
|
+
const DATA: ChartData = {
|
|
1566
|
+
labels: ['A', 'B', 'C'],
|
|
1567
|
+
datasets: [{ label: 'Market share', data: [50, 30, 20] }],
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
describe('drawPie', () => {
|
|
1571
|
+
it('calls clearRect with full dimensions', () => {
|
|
1572
|
+
const ctx = createMockCtx()
|
|
1573
|
+
drawPie(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['red', 'green', 'blue'])
|
|
1574
|
+
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 400, 300)
|
|
1575
|
+
})
|
|
1576
|
+
|
|
1577
|
+
it('draws one arc per slice', () => {
|
|
1578
|
+
const ctx = createMockCtx()
|
|
1579
|
+
drawPie(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['red', 'green', 'blue'])
|
|
1580
|
+
// 3 slices = 3 arc calls (each sector uses arc after moveTo)
|
|
1581
|
+
expect(ctx.arc.mock.calls.length).toBe(3)
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
it('fills each sector', () => {
|
|
1585
|
+
const ctx = createMockCtx()
|
|
1586
|
+
drawPie(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['red', 'green', 'blue'])
|
|
1587
|
+
expect(ctx.fill.mock.calls.length).toBe(3)
|
|
1588
|
+
})
|
|
1589
|
+
|
|
1590
|
+
it('strokes each sector boundary', () => {
|
|
1591
|
+
const ctx = createMockCtx()
|
|
1592
|
+
drawPie(ctx as unknown as CanvasRenderingContext2D, DATA, 400, 300, ['red', 'green', 'blue'])
|
|
1593
|
+
expect(ctx.stroke.mock.calls.length).toBe(3)
|
|
1594
|
+
})
|
|
1595
|
+
})
|
|
1596
|
+
|
|
1597
|
+
describe('hitTestPie', () => {
|
|
1598
|
+
it('returns null outside pie radius', () => {
|
|
1599
|
+
// Center is 200,150, radius = min(400,300)*0.4 = 120
|
|
1600
|
+
// Point at 0,0 is far outside
|
|
1601
|
+
expect(hitTestPie(0, 0, DATA, 400, 300)).toBeNull()
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
it('returns hit for point inside pie', () => {
|
|
1605
|
+
// A point near center should hit something
|
|
1606
|
+
const hit = hitTestPie(200, 148, DATA, 400, 300)
|
|
1607
|
+
expect(hit).not.toBeNull()
|
|
1608
|
+
expect(hit!.datasetIndex).toBe(0)
|
|
1609
|
+
})
|
|
1610
|
+
|
|
1611
|
+
it('returns correct slice for "A" (50% = top half area)', () => {
|
|
1612
|
+
// Slice A occupies from -PI/2 to PI/2 (top half going clockwise, 50%)
|
|
1613
|
+
// Point at (200, 80) is directly above center, inside pie — should be slice A
|
|
1614
|
+
const hit = hitTestPie(200, 80, DATA, 400, 300)
|
|
1615
|
+
expect(hit?.pointIndex).toBe(0)
|
|
1616
|
+
expect(hit?.label).toBe('A')
|
|
1617
|
+
})
|
|
1618
|
+
})
|
|
1619
|
+
```
|
|
1620
|
+
|
|
1621
|
+
- [ ] **Step 2: Run to verify FAIL**
|
|
1622
|
+
|
|
1623
|
+
```bash
|
|
1624
|
+
npx vitest run tests/components/chart/drawPie.test.ts
|
|
1625
|
+
```
|
|
1626
|
+
Expected: `Cannot find module '../../../src/components/chart/drawPie'`
|
|
1627
|
+
|
|
1628
|
+
- [ ] **Step 3: Create `src/components/chart/drawPie.ts`**
|
|
1629
|
+
|
|
1630
|
+
```ts
|
|
1631
|
+
import type { ChartData } from '../../types'
|
|
1632
|
+
import type { TooltipHit } from './chartTypes'
|
|
1633
|
+
|
|
1634
|
+
export function drawPie(
|
|
1635
|
+
ctx: CanvasRenderingContext2D,
|
|
1636
|
+
data: ChartData,
|
|
1637
|
+
w: number,
|
|
1638
|
+
h: number,
|
|
1639
|
+
colors: string[]
|
|
1640
|
+
): void {
|
|
1641
|
+
ctx.clearRect(0, 0, w, h)
|
|
1642
|
+
|
|
1643
|
+
const values = data.datasets[0]?.data ?? []
|
|
1644
|
+
const total = values.reduce((s, v) => s + v, 0) || 1
|
|
1645
|
+
const cx = w / 2
|
|
1646
|
+
const cy = h / 2
|
|
1647
|
+
const r = Math.min(w, h) * 0.4
|
|
1648
|
+
|
|
1649
|
+
let angle = -Math.PI / 2
|
|
1650
|
+
|
|
1651
|
+
for (const [i, v] of values.entries()) {
|
|
1652
|
+
const slice = (v / total) * Math.PI * 2
|
|
1653
|
+
ctx.beginPath()
|
|
1654
|
+
ctx.moveTo(cx, cy)
|
|
1655
|
+
ctx.arc(cx, cy, r, angle, angle + slice)
|
|
1656
|
+
ctx.closePath()
|
|
1657
|
+
ctx.fillStyle = colors[i]
|
|
1658
|
+
ctx.fill()
|
|
1659
|
+
ctx.strokeStyle = '#fff'
|
|
1660
|
+
ctx.lineWidth = 2
|
|
1661
|
+
ctx.stroke()
|
|
1662
|
+
angle += slice
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
export function hitTestPie(
|
|
1667
|
+
x: number,
|
|
1668
|
+
y: number,
|
|
1669
|
+
data: ChartData,
|
|
1670
|
+
w: number,
|
|
1671
|
+
h: number
|
|
1672
|
+
): TooltipHit | null {
|
|
1673
|
+
const cx = w / 2
|
|
1674
|
+
const cy = h / 2
|
|
1675
|
+
const r = Math.min(w, h) * 0.4
|
|
1676
|
+
|
|
1677
|
+
const dx = x - cx
|
|
1678
|
+
const dy = y - cy
|
|
1679
|
+
if (Math.hypot(dx, dy) > r) return null
|
|
1680
|
+
|
|
1681
|
+
const values = data.datasets[0]?.data ?? []
|
|
1682
|
+
const total = values.reduce((s, v) => s + v, 0) || 1
|
|
1683
|
+
|
|
1684
|
+
// Normalize angle so 0 = top (-PI/2), going clockwise
|
|
1685
|
+
let angle = (Math.atan2(dy, dx) + Math.PI * 2.5) % (Math.PI * 2)
|
|
1686
|
+
|
|
1687
|
+
let cumAngle = 0
|
|
1688
|
+
for (const [i, v] of values.entries()) {
|
|
1689
|
+
const slice = (v / total) * Math.PI * 2
|
|
1690
|
+
if (angle >= cumAngle && angle < cumAngle + slice) {
|
|
1691
|
+
return { datasetIndex: 0, pointIndex: i, value: v, label: data.labels[i] }
|
|
1692
|
+
}
|
|
1693
|
+
cumAngle += slice
|
|
1694
|
+
}
|
|
1695
|
+
return null
|
|
1696
|
+
}
|
|
1697
|
+
```
|
|
1698
|
+
|
|
1699
|
+
- [ ] **Step 4: Run to verify PASS**
|
|
1700
|
+
|
|
1701
|
+
```bash
|
|
1702
|
+
npx vitest run tests/components/chart/drawPie.test.ts
|
|
1703
|
+
```
|
|
1704
|
+
Expected: all 7 tests pass.
|
|
1705
|
+
|
|
1706
|
+
- [ ] **Step 5: Commit**
|
|
1707
|
+
|
|
1708
|
+
```bash
|
|
1709
|
+
git add src/components/chart/drawPie.ts tests/components/chart/drawPie.test.ts
|
|
1710
|
+
git commit -m "feat(charts): add drawPie and hitTestPie"
|
|
1711
|
+
```
|
|
1712
|
+
|
|
1713
|
+
---
|
|
1714
|
+
|
|
1715
|
+
## Task 11: VibeChartPie component
|
|
1716
|
+
|
|
1717
|
+
**Files:**
|
|
1718
|
+
- Create: `src/components/VibeChartPie.vue`
|
|
1719
|
+
- Create: `tests/components/VibeChartPie.test.ts`
|
|
1720
|
+
|
|
1721
|
+
- [ ] **Step 1: Write failing test**
|
|
1722
|
+
|
|
1723
|
+
```ts
|
|
1724
|
+
// tests/components/VibeChartPie.test.ts
|
|
1725
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
1726
|
+
import { mount } from '@vue/test-utils'
|
|
1727
|
+
import VibeChartPie from '../../src/components/VibeChartPie.vue'
|
|
1728
|
+
import { mockCanvas, mockResizeObserver } from '../mocks/canvasMock'
|
|
1729
|
+
import type { ChartData } from '../../src/types'
|
|
1730
|
+
|
|
1731
|
+
const DATA: ChartData = {
|
|
1732
|
+
labels: ['A', 'B', 'C'],
|
|
1733
|
+
datasets: [{ label: 'Market share', data: [50, 30, 20] }],
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
beforeEach(() => {
|
|
1737
|
+
mockCanvas()
|
|
1738
|
+
mockResizeObserver()
|
|
1739
|
+
})
|
|
1740
|
+
|
|
1741
|
+
afterEach(() => {
|
|
1742
|
+
vi.restoreAllMocks()
|
|
1743
|
+
vi.unstubAllGlobals()
|
|
1744
|
+
})
|
|
1745
|
+
|
|
1746
|
+
describe('VibeChartPie', () => {
|
|
1747
|
+
it('renders a canvas element', () => {
|
|
1748
|
+
const wrapper = mount(VibeChartPie, { props: { data: DATA } })
|
|
1749
|
+
expect(wrapper.find('canvas').exists()).toBe(true)
|
|
1750
|
+
})
|
|
1751
|
+
|
|
1752
|
+
it('renders legend at bottom by default', () => {
|
|
1753
|
+
const wrapper = mount(VibeChartPie, { props: { data: DATA } })
|
|
1754
|
+
expect(wrapper.find('.vibe-chart-legend--bottom').exists()).toBe(true)
|
|
1755
|
+
})
|
|
1756
|
+
|
|
1757
|
+
it('renders legend at top when legend="top"', () => {
|
|
1758
|
+
const wrapper = mount(VibeChartPie, { props: { data: DATA, legend: 'top' } })
|
|
1759
|
+
expect(wrapper.find('.vibe-chart-legend--top').exists()).toBe(true)
|
|
1760
|
+
})
|
|
1761
|
+
|
|
1762
|
+
it('hides legend when legend="none"', () => {
|
|
1763
|
+
const wrapper = mount(VibeChartPie, { props: { data: DATA, legend: 'none' } })
|
|
1764
|
+
expect(wrapper.find('.vibe-chart-legend').exists()).toBe(false)
|
|
1765
|
+
})
|
|
1766
|
+
|
|
1767
|
+
it('shows slice labels in legend (not dataset labels)', () => {
|
|
1768
|
+
const wrapper = mount(VibeChartPie, { props: { data: DATA } })
|
|
1769
|
+
const items = wrapper.findAll('.vibe-chart-legend-item')
|
|
1770
|
+
// Legend items = labels (A, B, C), not dataset label (Market share)
|
|
1771
|
+
expect(items).toHaveLength(3)
|
|
1772
|
+
expect(items[0].text()).toContain('A')
|
|
1773
|
+
expect(items[1].text()).toContain('B')
|
|
1774
|
+
expect(items[2].text()).toContain('C')
|
|
1775
|
+
})
|
|
1776
|
+
|
|
1777
|
+
it('calls clearRect when ResizeObserver triggers', () => {
|
|
1778
|
+
const ctx = mockCanvas()
|
|
1779
|
+
const ro = mockResizeObserver()
|
|
1780
|
+
mount(VibeChartPie, { props: { data: DATA } })
|
|
1781
|
+
ro.trigger(400, 400)
|
|
1782
|
+
expect(ctx.clearRect).toHaveBeenCalled()
|
|
1783
|
+
})
|
|
1784
|
+
|
|
1785
|
+
it('does not render axes or grid props', () => {
|
|
1786
|
+
// VibeChartPie has no showAxes/showGrid props — verify component mounts without them
|
|
1787
|
+
const wrapper = mount(VibeChartPie, { props: { data: DATA } })
|
|
1788
|
+
expect(wrapper.exists()).toBe(true)
|
|
1789
|
+
})
|
|
1790
|
+
})
|
|
1791
|
+
```
|
|
1792
|
+
|
|
1793
|
+
- [ ] **Step 2: Run to verify FAIL**
|
|
1794
|
+
|
|
1795
|
+
```bash
|
|
1796
|
+
npx vitest run tests/components/VibeChartPie.test.ts
|
|
1797
|
+
```
|
|
1798
|
+
Expected: `Cannot find module '../../src/components/VibeChartPie.vue'`
|
|
1799
|
+
|
|
1800
|
+
- [ ] **Step 3: Create `src/components/VibeChartPie.vue`**
|
|
1801
|
+
|
|
1802
|
+
```vue
|
|
1803
|
+
<script setup lang="ts">
|
|
1804
|
+
import { ref, computed, watch, onMounted, onUnmounted, type PropType } from 'vue'
|
|
1805
|
+
import type { ChartData, ChartLegendPosition } from '../types'
|
|
1806
|
+
import { resolveSliceColors } from './chart/chartColors'
|
|
1807
|
+
import { useChartResize } from './chart/chartResize'
|
|
1808
|
+
import { bindTooltip } from './chart/chartTooltip'
|
|
1809
|
+
import { drawPie, hitTestPie } from './chart/drawPie'
|
|
1810
|
+
|
|
1811
|
+
const props = defineProps({
|
|
1812
|
+
data: { type: Object as PropType<ChartData>, required: true },
|
|
1813
|
+
legend: { type: String as PropType<ChartLegendPosition>, default: 'bottom' },
|
|
1814
|
+
height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
|
|
1815
|
+
})
|
|
1816
|
+
|
|
1817
|
+
const containerEl = ref<HTMLElement | null>(null)
|
|
1818
|
+
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
|
1819
|
+
let cleanupTooltip: (() => void) | null = null
|
|
1820
|
+
let currentW = 0
|
|
1821
|
+
let currentH = 0
|
|
1822
|
+
|
|
1823
|
+
const canvasContainerStyle = computed(() => {
|
|
1824
|
+
if (props.height === 'auto') return { width: '100%', aspectRatio: '1/1' }
|
|
1825
|
+
const h = typeof props.height === 'number' ? `${props.height}px` : (props.height as string)
|
|
1826
|
+
return { width: '100%', height: h }
|
|
1827
|
+
})
|
|
1828
|
+
|
|
1829
|
+
const resolvedColors = computed(() =>
|
|
1830
|
+
containerEl.value ? resolveSliceColors(props.data.labels.length, containerEl.value) : []
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
function redraw() {
|
|
1834
|
+
const canvas = canvasEl.value!
|
|
1835
|
+
const ctx = canvas.getContext('2d')!
|
|
1836
|
+
const dpr = window.devicePixelRatio || 1
|
|
1837
|
+
canvas.width = currentW * dpr
|
|
1838
|
+
canvas.height = currentH * dpr
|
|
1839
|
+
ctx.scale(dpr, dpr)
|
|
1840
|
+
drawPie(ctx, props.data, currentW, currentH, resolvedColors.value)
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
useChartResize(containerEl, canvasEl, (w, h) => {
|
|
1844
|
+
currentW = w
|
|
1845
|
+
currentH = h
|
|
1846
|
+
redraw()
|
|
1847
|
+
})
|
|
1848
|
+
|
|
1849
|
+
onMounted(() => {
|
|
1850
|
+
cleanupTooltip = bindTooltip(
|
|
1851
|
+
canvasEl.value!,
|
|
1852
|
+
(x, y) => hitTestPie(x, y, props.data, currentW, currentH),
|
|
1853
|
+
redraw
|
|
1854
|
+
)
|
|
1855
|
+
})
|
|
1856
|
+
|
|
1857
|
+
watch(() => props.data, redraw, { deep: true })
|
|
1858
|
+
|
|
1859
|
+
onUnmounted(() => {
|
|
1860
|
+
cleanupTooltip?.()
|
|
1861
|
+
})
|
|
1862
|
+
</script>
|
|
1863
|
+
|
|
1864
|
+
<template>
|
|
1865
|
+
<div class="vibe-chart">
|
|
1866
|
+
<div v-if="legend === 'top'" class="vibe-chart-legend vibe-chart-legend--top">
|
|
1867
|
+
<span v-for="(label, i) in data.labels" :key="i" class="vibe-chart-legend-item">
|
|
1868
|
+
<span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
|
|
1869
|
+
{{ label }}
|
|
1870
|
+
</span>
|
|
1871
|
+
</div>
|
|
1872
|
+
<div ref="containerEl" class="vibe-chart-canvas-container" :style="canvasContainerStyle">
|
|
1873
|
+
<canvas ref="canvasEl" style="display: block; width: 100%; height: 100%;" />
|
|
1874
|
+
</div>
|
|
1875
|
+
<div v-if="legend === 'bottom'" class="vibe-chart-legend vibe-chart-legend--bottom">
|
|
1876
|
+
<span v-for="(label, i) in data.labels" :key="i" class="vibe-chart-legend-item">
|
|
1877
|
+
<span class="vibe-chart-legend-swatch" :style="{ background: resolvedColors[i] }" />
|
|
1878
|
+
{{ label }}
|
|
1879
|
+
</span>
|
|
1880
|
+
</div>
|
|
1881
|
+
</div>
|
|
1882
|
+
</template>
|
|
1883
|
+
|
|
1884
|
+
<style scoped>
|
|
1885
|
+
.vibe-chart { width: 100%; }
|
|
1886
|
+
.vibe-chart-legend {
|
|
1887
|
+
display: flex;
|
|
1888
|
+
flex-wrap: wrap;
|
|
1889
|
+
gap: 0.5rem;
|
|
1890
|
+
padding: 0.25rem 0;
|
|
1891
|
+
}
|
|
1892
|
+
.vibe-chart-legend--top { margin-bottom: 0.25rem; }
|
|
1893
|
+
.vibe-chart-legend--bottom { margin-top: 0.25rem; }
|
|
1894
|
+
.vibe-chart-legend-item {
|
|
1895
|
+
display: flex;
|
|
1896
|
+
align-items: center;
|
|
1897
|
+
gap: 0.25rem;
|
|
1898
|
+
font-size: 0.875rem;
|
|
1899
|
+
}
|
|
1900
|
+
.vibe-chart-legend-swatch {
|
|
1901
|
+
display: inline-block;
|
|
1902
|
+
width: 12px;
|
|
1903
|
+
height: 12px;
|
|
1904
|
+
border-radius: 2px;
|
|
1905
|
+
flex-shrink: 0;
|
|
1906
|
+
}
|
|
1907
|
+
</style>
|
|
1908
|
+
```
|
|
1909
|
+
|
|
1910
|
+
- [ ] **Step 4: Run to verify PASS**
|
|
1911
|
+
|
|
1912
|
+
```bash
|
|
1913
|
+
npx vitest run tests/components/VibeChartPie.test.ts
|
|
1914
|
+
```
|
|
1915
|
+
Expected: all 7 tests pass.
|
|
1916
|
+
|
|
1917
|
+
- [ ] **Step 5: Commit**
|
|
1918
|
+
|
|
1919
|
+
```bash
|
|
1920
|
+
git add src/components/VibeChartPie.vue tests/components/VibeChartPie.test.ts
|
|
1921
|
+
git commit -m "feat(charts): add VibeChartPie component with slice-based legend and colors"
|
|
1922
|
+
```
|
|
1923
|
+
|
|
1924
|
+
---
|
|
1925
|
+
|
|
1926
|
+
## Task 12: Wire exports and full test run
|
|
1927
|
+
|
|
1928
|
+
**Files:**
|
|
1929
|
+
- Modify: `src/types.ts` (already done in Task 1 — verify only)
|
|
1930
|
+
- Modify: `src/components/index.ts`
|
|
1931
|
+
- Modify: `src/index.ts`
|
|
1932
|
+
|
|
1933
|
+
- [ ] **Step 1: Add imports, exports, and plugin registration to `src/components/index.ts`**
|
|
1934
|
+
|
|
1935
|
+
Add after existing imports (around line with `// Export all components`):
|
|
1936
|
+
|
|
1937
|
+
```ts
|
|
1938
|
+
// Chart Components
|
|
1939
|
+
import VibeChartLine from './VibeChartLine.vue'
|
|
1940
|
+
import VibeChartBar from './VibeChartBar.vue'
|
|
1941
|
+
import VibeChartPie from './VibeChartPie.vue'
|
|
1942
|
+
```
|
|
1943
|
+
|
|
1944
|
+
Add to the `export { ... }` block:
|
|
1945
|
+
```ts
|
|
1946
|
+
// Chart
|
|
1947
|
+
VibeChartLine,
|
|
1948
|
+
VibeChartBar,
|
|
1949
|
+
VibeChartPie,
|
|
1950
|
+
```
|
|
1951
|
+
|
|
1952
|
+
Add to the `install(app)` function (after `// Data` section):
|
|
1953
|
+
```ts
|
|
1954
|
+
// Chart
|
|
1955
|
+
app.component('VibeChartLine', VibeChartLine)
|
|
1956
|
+
app.component('VibeChartBar', VibeChartBar)
|
|
1957
|
+
app.component('VibeChartPie', VibeChartPie)
|
|
1958
|
+
```
|
|
1959
|
+
|
|
1960
|
+
- [ ] **Step 2: Add re-exports to `src/index.ts`**
|
|
1961
|
+
|
|
1962
|
+
Add after existing component exports:
|
|
1963
|
+
```ts
|
|
1964
|
+
export { default as VibeChartLine } from './components/VibeChartLine.vue'
|
|
1965
|
+
export { default as VibeChartBar } from './components/VibeChartBar.vue'
|
|
1966
|
+
export { default as VibeChartPie } from './components/VibeChartPie.vue'
|
|
1967
|
+
export type { ChartData, ChartDataset, ChartLegendPosition } from './types'
|
|
1968
|
+
```
|
|
1969
|
+
|
|
1970
|
+
- [ ] **Step 3: Run full test suite**
|
|
1971
|
+
|
|
1972
|
+
```bash
|
|
1973
|
+
npx vitest run
|
|
1974
|
+
```
|
|
1975
|
+
Expected: all tests pass, no failures.
|
|
1976
|
+
|
|
1977
|
+
- [ ] **Step 4: TypeScript check**
|
|
1978
|
+
|
|
1979
|
+
```bash
|
|
1980
|
+
npx vue-tsc --noEmit
|
|
1981
|
+
```
|
|
1982
|
+
Expected: no errors.
|
|
1983
|
+
|
|
1984
|
+
- [ ] **Step 5: Commit**
|
|
1985
|
+
|
|
1986
|
+
```bash
|
|
1987
|
+
git add src/components/index.ts src/index.ts
|
|
1988
|
+
git commit -m "feat(charts): register and export VibeChartLine, VibeChartBar, VibeChartPie"
|
|
1989
|
+
```
|
|
1990
|
+
|
|
1991
|
+
---
|
|
1992
|
+
|
|
1993
|
+
## Self-Review Notes
|
|
1994
|
+
|
|
1995
|
+
- **Spec coverage:** All props covered (legend, height, showAxes, showGrid, smooth, fill, stacked). Color cycling via BS vars ✓. ResizeObserver ✓. Tooltip ✓. No fallbacks ✓. No external deps ✓.
|
|
1996
|
+
- **Type consistency:** `TooltipHit` defined once in `chartTypes.ts`, imported by drawLine/drawBar/drawPie and chartTooltip. `ChartData`/`ChartDataset`/`ChartLegendPosition` defined once in `src/types.ts`.
|
|
1997
|
+
- **Pie legend:** Iterates `data.labels` (slice names), not `data.datasets` (dataset names) — matches spec intent.
|
|
1998
|
+
- **Pie aspect ratio:** Default is `1/1` (square) rather than `16/9` — more appropriate for a pie chart.
|
|
1999
|
+
- **No placeholders:** All steps contain complete code.
|