From 90df895bd214d2b4493a6223d9efe15d1fa4f8bd Mon Sep 17 00:00:00 2001 From: Matt Stover Date: Thu, 16 Apr 2026 16:41:16 -0700 Subject: [PATCH 1/3] feat: create new pie chart component along with stories and tests --- @kiva/kv-components/jest.config.cjs | 7 + .../kv-components/src/utils/pieChartColors.ts | 39 ++ @kiva/kv-components/src/utils/useCountUp.ts | 76 +++ @kiva/kv-components/src/vue/KvPieChartV2.vue | 604 ++++++++++++++++++ @kiva/kv-components/src/vue/index.ts | 1 + .../src/vue/stories/KvPieChartV2.stories.js | 475 ++++++++++++++ .../src/vue/stories/KvPieChartV2Docs.mdx | 272 ++++++++ @kiva/kv-components/tests/unit/.eslintrc | 3 + .../specs/components/KvPieChartV2.spec.ts | 174 +++++ .../unit/specs/utils/pieChartColors.spec.ts | 50 ++ .../tests/unit/specs/utils/useCountUp.spec.ts | 110 ++++ 11 files changed, 1811 insertions(+) create mode 100644 @kiva/kv-components/src/utils/pieChartColors.ts create mode 100644 @kiva/kv-components/src/utils/useCountUp.ts create mode 100644 @kiva/kv-components/src/vue/KvPieChartV2.vue create mode 100644 @kiva/kv-components/src/vue/stories/KvPieChartV2.stories.js create mode 100644 @kiva/kv-components/src/vue/stories/KvPieChartV2Docs.mdx create mode 100644 @kiva/kv-components/tests/unit/specs/components/KvPieChartV2.spec.ts create mode 100644 @kiva/kv-components/tests/unit/specs/utils/pieChartColors.spec.ts create mode 100644 @kiva/kv-components/tests/unit/specs/utils/useCountUp.spec.ts diff --git a/@kiva/kv-components/jest.config.cjs b/@kiva/kv-components/jest.config.cjs index 4ef5db721..287fcab73 100644 --- a/@kiva/kv-components/jest.config.cjs +++ b/@kiva/kv-components/jest.config.cjs @@ -51,8 +51,15 @@ module.exports = { }], '^.+\\.ts$': ['ts-jest', { useESM: true, + diagnostics: false, tsconfig: { allowJs: true, + types: ['jest', 'node'], + baseUrl: '.', + paths: { + '#components/*': ['src/vue/*'], + '#utils/*': ['src/utils/*'], + }, }, }], '^.+\\.js$': 'babel-jest', diff --git a/@kiva/kv-components/src/utils/pieChartColors.ts b/@kiva/kv-components/src/utils/pieChartColors.ts new file mode 100644 index 000000000..06d06334d --- /dev/null +++ b/@kiva/kv-components/src/utils/pieChartColors.ts @@ -0,0 +1,39 @@ +import kvTokensPrimitives from '@kiva/kv-tokens'; + +const { colors } = kvTokensPrimitives; + +/** + * Tiered chart color palette drawn from kv-tokens design primitives. + * Tier 1 (indices 0-7): Bold primary colors for maximum visual distinction. + * Tier 2 (indices 8-13): Lighter variants for datasets with more segments. + * Colors cycle when the dataset exceeds palette length. + */ +export const PIE_CHART_COLORS: string[] = [ + // Tier 1 — Bold primaries + colors['eco-green']['3'], // #276A43 + colors.marigold.DEFAULT, // #F4B539 + colors['desert-rose'].DEFAULT, // #C45F4F + '#2E3BAD', // Custom blue for contrast + '#874FFF', // Custom purple for contrast + colors.stone['3'], // #635544 + colors.brand['500'], // #4AB67E + colors.marigold['3'], // #996210 + // Tier 2 — Lighter variants + colors['eco-green']['2'], // #78C79F + colors.marigold['2'], // #F8CD69 + colors['desert-rose']['2'], // #E0988D + colors.stone['2'], // #AA9E8D + colors.brand['300'], // #95D4B3 + colors['desert-rose']['3'], // #A24536 +]; + +/** + * Get the chart color for a given index, with optional override. + * Cycles through the palette when index exceeds palette length. + */ +export function getPieChartColor(index: number, customColor?: string): string { + if (customColor) { + return customColor; + } + return PIE_CHART_COLORS[index % PIE_CHART_COLORS.length]; +} diff --git a/@kiva/kv-components/src/utils/useCountUp.ts b/@kiva/kv-components/src/utils/useCountUp.ts new file mode 100644 index 000000000..31f06b07b --- /dev/null +++ b/@kiva/kv-components/src/utils/useCountUp.ts @@ -0,0 +1,76 @@ +import { + ref, + watch, + onUnmounted, + type Ref, +} from 'vue'; + +/** + * Cubic ease-in-out function. + */ +export function easeInOutCubic(t: number): number { + return t < 0.5 + ? 4 * t * t * t + : 1 - (-2 * t + 2) ** 3 / 2; +} + +/** + * Composable that animates a number from 0 to a target value (or back to 0) + * using requestAnimationFrame with cubic ease-in-out easing. + * + * @param target - Reactive ref of the target number to animate toward + * @param active - Reactive ref controlling animation direction (true = animate to target, false = animate to 0) + * @param duration - Animation duration in milliseconds (default: 500) + * @returns Object with displayValue ref containing the current animated number + */ +export function useCountUp(target: Ref, active: Ref, duration = 500) { + const displayValue = ref(0); + let rafId: number | null = null; + let startTime: number | null = null; + let startValue = 0; + + function animate(endValue: number) { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + startTime = null; + startValue = displayValue.value; + + function step(timestamp: number) { + if (startTime === null) { + startTime = timestamp; + } + const elapsed = timestamp - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = easeInOutCubic(progress); + + displayValue.value = Math.round(startValue + (endValue - startValue) * eased); + + if (progress < 1) { + rafId = requestAnimationFrame(step); + } else { + rafId = null; + } + } + + rafId = requestAnimationFrame(step); + } + + watch(active, (isActive) => { + animate(isActive ? target.value : 0); + }, { immediate: true }); + + watch(target, (newTarget) => { + if (active.value) { + animate(newTarget); + } + }); + + onUnmounted(() => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }); + + return { displayValue }; +} diff --git a/@kiva/kv-components/src/vue/KvPieChartV2.vue b/@kiva/kv-components/src/vue/KvPieChartV2.vue new file mode 100644 index 000000000..900b8ba85 --- /dev/null +++ b/@kiva/kv-components/src/vue/KvPieChartV2.vue @@ -0,0 +1,604 @@ + + + + + diff --git a/@kiva/kv-components/src/vue/index.ts b/@kiva/kv-components/src/vue/index.ts index 757a77444..5fd281593 100644 --- a/@kiva/kv-components/src/vue/index.ts +++ b/@kiva/kv-components/src/vue/index.ts @@ -58,6 +58,7 @@ export { default as KvMaterialIcon } from './KvMaterialIcon.vue'; export { default as KvPageContainer } from './KvPageContainer.vue'; export { default as KvPagination } from './KvPagination.vue'; export { default as KvPieChart } from './KvPieChart.vue'; +export { default as KvPieChartV2 } from './KvPieChartV2.vue'; export { default as KvPill } from './KvPill.vue'; export { default as KvPopper } from './KvPopper.vue'; export { default as KvProgressBar } from './KvProgressBar.vue'; diff --git a/@kiva/kv-components/src/vue/stories/KvPieChartV2.stories.js b/@kiva/kv-components/src/vue/stories/KvPieChartV2.stories.js new file mode 100644 index 000000000..7d7e04bef --- /dev/null +++ b/@kiva/kv-components/src/vue/stories/KvPieChartV2.stories.js @@ -0,0 +1,475 @@ +import KvPieChartV2 from '../KvPieChartV2.vue'; +import KvPieChartV2DocsMdx from './KvPieChartV2Docs.mdx'; + +const sampleValues = [ + { label: 'Agriculture', value: 28 }, + { label: 'Eco-friendly', value: 28 }, + { label: 'Services', value: 17 }, + { label: 'Water', value: 13 }, + { label: 'Food', value: 12 }, + { label: 'Other', value: 5 }, +]; + +const fewValues = [ + { label: 'Female', value: 75 }, + { label: 'Male', value: 25 }, +]; + +const manyValues = [ + { label: 'Food', value: 575 }, + { label: 'Retail', value: 377 }, + { label: 'Agriculture', value: 285 }, + { label: 'Services', value: 211 }, + { label: 'Clothing', value: 183 }, + { label: 'Arts', value: 65 }, + { label: 'Housing', value: 65 }, + { label: 'Education', value: 36 }, + { label: 'Construction', value: 28 }, + { label: 'Health', value: 27 }, + { label: 'Transportation', value: 23 }, + { label: 'Personal Use', value: 19 }, + { label: 'Manufacturing', value: 13 }, + { label: 'Entertainment', value: 10 }, + { label: 'Wholesale', value: 5 }, +]; + +const customColorValues = [ + { label: 'Category A', value: 40, color: '#FF6B6B' }, + { label: 'Category B', value: 30, color: '#4ECDC4' }, + { label: 'Category C', value: 20 }, + { label: 'Category D', value: 10 }, +]; + +export default { + title: 'Charts/KvPieChartV2', + component: KvPieChartV2, + parameters: { + docs: { + page: KvPieChartV2DocsMdx, + title: 'KvPieChartV2 Docs', + }, + }, + argTypes: { + /** + * Chart data items. Each must have a label and numeric value; optional color override. + */ + values: { + control: 'object', + description: 'Chart data items. Each must have a label and numeric value; optional color override.', + table: { + type: { summary: 'KvPieChartV2Item[]' }, + defaultValue: { summary: '[]' }, + }, + }, + /** + * How values are displayed in legend pills. + */ + unit: { + control: { type: 'radio' }, + options: ['percent', 'amount', 'count'], + description: 'How values are displayed in legend pills.', + table: { + type: { summary: "'percent' | 'amount' | 'count'" }, + defaultValue: { summary: "'percent'" }, + }, + }, + /** + * Shows a skeleton donut ring when true. + */ + loading: { + control: 'boolean', + description: 'Shows a skeleton donut ring when true.', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, + }, + }, + /** + * Maximum number of visible segments before remaining items collapse into "Other". + */ + shownSegments: { + control: { type: 'number', min: 1, max: 20 }, + description: 'Maximum number of visible segments before remaining items collapse into "Other".', + table: { + type: { summary: 'number' }, + defaultValue: { summary: '5' }, + }, + }, + /** + * Donut ring thickness in SVG user units. The radius shrinks as this grows, + * so the outer edge always fits within the viewBox. + */ + strokeWidth: { + control: { + type: 'range', min: 8, max: 200, step: 2, + }, + description: 'Donut ring thickness in SVG user units.', + table: { + type: { summary: 'number' }, + defaultValue: { summary: '56' }, + }, + }, + /** + * Visual gap between adjacent segments, in SVG user units along the circumference. + */ + segmentGap: { + control: { + type: 'range', min: 0, max: 20, step: 1, + }, + description: 'Visual gap between adjacent segments, in SVG user units along the circumference.', + table: { + type: { summary: 'number' }, + defaultValue: { summary: '2' }, + }, + }, + /** + * Delay in milliseconds before the first segment starts animating in. + */ + initialDelay: { + control: { + type: 'range', min: 0, max: 5000, step: 100, + }, + description: 'Delay in milliseconds before the first segment starts animating in.', + table: { + type: { summary: 'number' }, + defaultValue: { summary: '1000' }, + }, + }, + /** + * Reverses the entrance animation (segments shrink back to 0). + */ + animateOut: { + control: 'boolean', + description: 'Reverses the entrance animation (segments shrink back to 0).', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, + }, + }, + }, +}; + +/** + * Default story - Interactive playground for exploring all props. + */ +export const Default = { + args: { + values: sampleValues, + unit: 'percent', + loading: false, + shownSegments: 5, + strokeWidth: 56, + segmentGap: 2, + initialDelay: 1000, + animateOut: false, + }, + render: (args) => ({ + components: { KvPieChartV2 }, + setup() { + return { args }; + }, + template: ` +
+
+ +
+
+ `, + }), +}; + +/** + * Component Overview - Key visual variants at a glance. + */ +export const ComponentOverview = { + render: () => ({ + components: { KvPieChartV2 }, + setup() { + return { sampleValues, fewValues, manyValues }; + }, + template: ` +
+
+ +

Default (6 items)

+
+
+ +

Few items

+
+
+ +

Many items (Other pill)

+
+
+ +

Loading skeleton

+
+
+ `, + }), +}; + +/** + * All Variations - Every prop axis shown in organized groups. + */ +export const AllVariations = { + render: () => ({ + components: { KvPieChartV2 }, + setup() { + return { + sampleValues, manyValues, customColorValues, + }; + }, + template: ` +
+
+

Unit Formats

+
+
+ +

percent

+
+
+ +

amount

+
+
+ +

count

+
+
+
+ +
+

Ring Thickness

+
+
+ +

strokeWidth: 24

+
+
+ +

strokeWidth: 56 (default)

+
+
+ +

strokeWidth: 100

+
+
+
+ +
+

Segment Gap

+
+
+ +

segmentGap: 0

+
+
+ +

segmentGap: 2 (default)

+
+
+ +

segmentGap: 10

+
+
+
+ +
+

Data Density

+
+
+ +

Single value

+
+
+ +

Many values, shownSegments: 4

+
+
+ +

Custom color overrides

+
+
+
+
+ `, + }), +}; + +/** + * Loading State - Skeleton ring while data is unavailable. + */ +export const LoadingState = { + render: () => ({ + components: { KvPieChartV2 }, + template: ` +
+
+ +
+
+ `, + }), +}; + +/** + * Unit Formats - Side-by-side comparison of the three display modes. + */ +export const UnitFormats = { + render: () => ({ + components: { KvPieChartV2 }, + setup() { + return { sampleValues }; + }, + template: ` +
+
+ +

percent

+
+
+ +

amount

+
+
+ +

count

+
+
+ `, + }), +}; + +/** + * Ring Thickness - strokeWidth scales inward so outer diameter stays constant. + */ +export const RingThickness = { + render: () => ({ + components: { KvPieChartV2 }, + setup() { + return { sampleValues }; + }, + template: ` +
+
+ +

16 (thin)

+
+
+ +

56 (default)

+
+
+ +

120 (thick)

+
+
+ `, + }), +}; + +/** + * Segment Gap - Visual spacing between adjacent segments. + */ +export const SegmentGap = { + render: () => ({ + components: { KvPieChartV2 }, + setup() { + return { sampleValues }; + }, + template: ` +
+
+ +

No gap

+
+
+ +

2 (default)

+
+
+ +

10 (wide)

+
+
+ `, + }), +}; + +/** + * Data Density - Single value, few items, and overflow into "Other". + */ +export const DataDensity = { + render: () => ({ + components: { KvPieChartV2 }, + setup() { + return { fewValues, manyValues }; + }, + template: ` +
+
+ +

Single value

+
+
+ +

Few items

+
+
+ +

Many items (click Other)

+
+
+ `, + }), +}; + +/** + * Custom Colors - Partial or full color overrides per item. + */ +export const CustomColors = { + render: () => ({ + components: { KvPieChartV2 }, + setup() { + return { customColorValues }; + }, + template: ` +
+
+ +
+
+ `, + }), +}; + +/** + * Animation Timing - Initial delay options including no delay. + */ +export const AnimationTiming = { + render: () => ({ + components: { KvPieChartV2 }, + setup() { + return { sampleValues }; + }, + template: ` +
+
+ +

initialDelay: 0

+
+
+ +

initialDelay: 1000 (default)

+
+
+ +

initialDelay: 2500

+
+
+ `, + }), +}; diff --git a/@kiva/kv-components/src/vue/stories/KvPieChartV2Docs.mdx b/@kiva/kv-components/src/vue/stories/KvPieChartV2Docs.mdx new file mode 100644 index 000000000..e1389ebab --- /dev/null +++ b/@kiva/kv-components/src/vue/stories/KvPieChartV2Docs.mdx @@ -0,0 +1,272 @@ +import { Canvas, Meta, Story, Controls } from '@storybook/addon-docs/blocks'; +import * as KvPieChartV2Stories from './KvPieChartV2.stories.js'; + + + +# KvPieChartV2 + +An animated donut chart with an accompanying legend, designed for compact displays of categorical breakdowns with optional overflow into an "Other" lightbox. + +## Component Overview + +KvPieChartV2 renders a sorted donut chart with one ring segment per data item and a two-column legend of color-coded pills. Segments animate in with a staggered grow effect while their values count up from zero. When the dataset exceeds `shownSegments`, the remaining items collapse into an "Other" segment whose pill opens a lightbox listing every item in descending order. + + + +## Table of Contents + +- [Variations](#variations) +- [Usage Information](#usage-information) +- [Behavior](#behavior) +- [Anatomy](#anatomy) +- [Specs](#specs) +- [Best Practices](#best-practices) +- [Accessibility](#accessibility) +- [Component Properties + Demo](#component-properties) +- [Code Examples](#code-examples) + +--- + +## Variations + +The component exposes several axes of customization: + +- **Unit Format**: Display legend values as `percent`, `amount` (with `$` prefix), or raw `count` +- **Ring Thickness**: `strokeWidth` controls how thick the donut ring is; the radius shrinks as it grows so the outer edge always fits the viewBox +- **Segment Gap**: `segmentGap` adds visual breathing room between adjacent segments +- **Segment Cap**: `shownSegments` caps the number of distinct segments before the rest collapse into "Other" +- **Custom Colors**: Each item can override its assigned palette color via an optional `color` field +- **Animation Timing**: `initialDelay` postpones the entrance animation; `animateOut` reverses it + + + +## Usage Information + +KvPieChartV2 is intended for compact dashboard tiles, profile cards, and modal panels where a categorical breakdown should feel approachable rather than data-dense. The animated entrance draws attention to the chart on first view, and the "Other" overflow keeps the legend scannable even when the underlying dataset is long. + +### When to Use + +- To show how a small number of categories add up to a whole (sectors, gender, status, etc.) +- In compact contexts (~262px wide) where a full chart library would be overkill +- When animated reveals add value to the experience (e.g., on loan or impact pages) +- When you want a clickable "Other" affordance for long-tail data + +### When Not to Use + +- For precise, comparison-heavy data — use a bar chart or table instead +- For time-series data — use [KvLineGraph](/?path=/docs/charts-kvlinegraph--docs) +- When the dataset has dozens of equally-weighted categories that resist meaningful collapse +- For interactive drill-down or hover tooltips — the component is read-only beyond the "Other" lightbox + +--- + +## Behavior + +The chart's behavior centers on its entrance animation, the "Other" overflow affordance, and the loading skeleton. + +### Loading State + +When `loading` is true, the chart renders a neutral gray skeleton ring in place of the segments and hides the legend. This keeps layout stable while data fetches and avoids partial renders. As soon as `loading` flips to false (with `values` present), the entrance animation begins after `initialDelay`. + + + +### Other Segment Overflow + +When the number of items exceeds `shownSegments`, all overflow items are merged into a single gray "Other" segment in the ring and a corresponding "Other" pill in the legend. Clicking the pill opens a lightbox showing **every** item — both visible and collapsed — sorted in descending order with their assigned colors. This lets consumers scan the long tail without inflating the chart itself. + + + +## Anatomy + +The component is composed of: + +- **Donut ring**: An SVG with one `` per visible segment and an optional gray "Other" circle, all rendered with `stroke-dasharray` to control segment length +- **Skeleton ring**: A neutral gray placeholder circle shown while `loading` is true or when no data is present +- **Legend grid**: A two-column grid of color-coded pills, each showing the segment label and its animated value +- **Other pill**: A trigger pill that opens the lightbox when overflow exists +- **Other lightbox**: A modal listing all items in descending order, rendered via [KvLightbox](/?path=/docs/components-kvlightbox--docs) + +--- + +## Specs + +### Ring Thickness + +The donut renders inside a fixed `262 × 262` viewBox. The radius is derived from `strokeWidth` so the outer edge always lands on the viewBox boundary — thicker rings grow inward toward center rather than overflowing the SVG. Common values: + +- **16-24**: Thin, subtle rings +- **56** (default): Balanced standard appearance +- **80-120**: Bold, prominent rings (smaller inner hole) + + + +### Segment Gap + +The `segmentGap` prop subtracts visual length (in SVG user units along the circumference) from each segment so adjacent segments appear separated. A value of `0` produces a continuous ring; the default of `2` matches the design spec; values around `8-10` create distinct "petal" segments. + +### Animation Timing + +The entrance animation runs in three coordinated phases: + +- **Initial delay** (`initialDelay`, default `1000ms`): Wait before any segment animates +- **Per-segment grow** (500ms): Each segment expands clockwise from its starting angle +- **Stagger** (500ms): Each segment begins as the previous one completes + +Count-up animations on legend pill values run in parallel with their corresponding segment animations using cubic ease-in-out. + +--- + +## Best Practices + +
+
+
+ +
+
✓ Do
+

Cap visible segments at 5–6 with `shownSegments` and let the rest collapse into "Other" so the legend stays scannable.

+
+ +
+
+ +
+
✗ Don't
+

Render every category as its own segment. Crowded rings make individual slices unreadable and force the legend to grow indefinitely.

+
+ +
+
+ +
+
✓ Do
+

Pick the `unit` that matches the underlying data: `percent` for share-of-whole, `amount` for currency, `count` for raw tallies.

+
+ +
+
+ +
+
✗ Don't
+

Display amounts as percents (or vice versa) just because it looks tidier. The unit must reflect what the chart actually measures.

+
+
+ +--- + +## Accessibility + +- The chart `
` carries an `aria-label="Pie chart"` so it is announced as an image to assistive tech +- The decorative SVG is marked `aria-hidden="true"` since the legend pills carry the same data in textual form +- The "Other" pill is a real `