From 8e0bddb20e607dfec827e797d5a49c266d4bb95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ay=C5=9Feturmusn?= Date: Thu, 22 Jan 2026 22:10:02 +0300 Subject: [PATCH 1/5] feat(ChartPie): implement pie chart with Loader, Text, and stories --- .../admin/dashboard/ChartPie.stories.svelte | 222 +++++++++ .../admin/dashboard/ChartPie.svelte | 444 ++++++++++++++++++ hexawebshare/src/lib/index.ts | 7 + 3 files changed, 673 insertions(+) create mode 100644 hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte diff --git a/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte b/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte new file mode 100644 index 00000000..d3bd0af9 --- /dev/null +++ b/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hexawebshare/src/components/admin/dashboard/ChartPie.svelte b/hexawebshare/src/components/admin/dashboard/ChartPie.svelte index 315da078..9b4f9c56 100644 --- a/hexawebshare/src/components/admin/dashboard/ChartPie.svelte +++ b/hexawebshare/src/components/admin/dashboard/ChartPie.svelte @@ -2,3 +2,447 @@ SPDX-FileCopyrightText: 2025 hexaTune LLC SPDX-License-Identifier: MIT --> + + + +
+
+ {accessibleDescription} +
+ + {#if loading} + +
+ +
+ {:else if error} + +
+ +
+ {:else if !hasData} + +
+ +
+ {:else} +
+
+ + {#if showLabels} + + {/if} +
+ {#if showLegend && legendItemsWithColors.length > 0} + + {/if} +
+ {/if} +
diff --git a/hexawebshare/src/lib/index.ts b/hexawebshare/src/lib/index.ts index e003cbdb..365dde4b 100644 --- a/hexawebshare/src/lib/index.ts +++ b/hexawebshare/src/lib/index.ts @@ -102,6 +102,13 @@ export { default as MutedText } from '../components/core/typography/MutedText.sv export { default as Paragraph } from '../components/core/typography/Paragraph.svelte'; export { default as Text } from '../components/core/typography/Text.svelte'; +// Admin / Dashboard +export { default as ChartPie } from '../components/admin/dashboard/ChartPie.svelte'; +export type { + ChartPieDataPoint, + ChartPieVariant +} from '../components/admin/dashboard/ChartPie.svelte'; + // Utility / Utility export { default as FooterBar } from '../components/utility/utility/FooterBar.svelte'; export type { From 31f874f539e946fef5f2b8f3f2fba277c769480b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ay=C5=9Feturmusn?= Date: Thu, 22 Jan 2026 23:15:37 +0300 Subject: [PATCH 2/5] feat(ChartPie): add legend customization and slice label options - Add showOnlyPercentage prop to show only percentage on slices - Add useColoredBadges prop to customize badge colors in legend - Add hideLegendLabels prop to hide category names in legend - Add hideLabeledFromLegend prop to exclude labeled slices from legend - Make SVG dimensions dynamic based on size prop - Implement custom legend with colored badges matching slice colors - Update Storybook stories with new props and examples --- .../admin/dashboard/ChartPie.stories.svelte | 84 +++++++++- .../admin/dashboard/ChartPie.svelte | 153 +++++++++++++----- 2 files changed, 196 insertions(+), 41 deletions(-) diff --git a/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte b/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte index d3bd0af9..556f370f 100644 --- a/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte +++ b/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte @@ -21,8 +21,9 @@ SPDX-License-Identifier: MIT tags: ['autodocs'], argTypes: { data: { - control: 'object', - description: 'Data array - each item represents a slice in the pie chart' + control: { type: 'object' }, + description: + 'Data array - each item represents a slice in the pie chart. Format: [{ label: string, value: number }, ...]' }, colorScheme: { control: 'object', @@ -45,10 +46,28 @@ SPDX-License-Identifier: MIT control: 'boolean', description: 'Show values on slices or in legend' }, + showOnlyPercentage: { + control: 'boolean', + description: 'Show only percentage on slices, hide category labels' + }, showLegend: { control: 'boolean', description: 'Show legend below chart' }, + hideLegendLabels: { + control: 'boolean', + description: 'Hide category labels in legend, show only color and value' + }, + useColoredBadges: { + control: 'boolean', + description: + 'Use colored badges in legend that match slice colors. When false, all badges use neutral color.' + }, + hideLabeledFromLegend: { + control: 'boolean', + description: + 'Hide legend items for slices that have labels displayed on the chart. When false, all slices are shown in legend.' + }, labelMinPercentage: { control: { type: 'number', min: 0, max: 50, step: 1 }, description: 'Minimum slice % to show label on chart (avoids overlap)' @@ -96,7 +115,11 @@ SPDX-License-Identifier: MIT size: 'md', showLabels: true, showValues: true, + showOnlyPercentage: true, showLegend: true, + hideLegendLabels: false, + useColoredBadges: true, + hideLabeledFromLegend: false, labelMinPercentage: 15, loading: false, disabled: false, @@ -204,6 +227,59 @@ SPDX-License-Identifier: MIT }} /> + + + + + + + + = labelMinPercentage) are excluded from legend. + * Only small slices without labels will be shown in legend. + * When false, all slices are shown in legend regardless of label visibility. + * @default false + */ + hideLabeledFromLegend?: boolean; /** * Minimum slice percentage to show label on chart (avoids overlap on small adjacent slices). * Smaller slices are still in legend and tooltip. @@ -144,7 +169,11 @@ SPDX-License-Identifier: MIT size = 'md', showLabels = true, showValues = true, + showOnlyPercentage = true, showLegend = true, + hideLegendLabels = false, + useColoredBadges = true, + hideLabeledFromLegend = false, labelMinPercentage = 15, loading = false, disabled = false, @@ -160,16 +189,16 @@ SPDX-License-Identifier: MIT ...props }: Props = $props(); - // SVG dimensions - const SVG_SIZE = 200; - const CX = SVG_SIZE / 2; - const CY = SVG_SIZE / 2; - const OUTER_R = (SVG_SIZE / 2) * 0.9; - let innerR = $derived(donut ? OUTER_R * 0.55 : 0); - // Size-based chart dimensions let chartSizePx = $derived(size === 'sm' ? 160 : size === 'md' ? 200 : 280); + // SVG dimensions (dynamic based on size prop) + let SVG_SIZE = $derived(chartSizePx); + let CX = $derived(SVG_SIZE / 2); + let CY = $derived(SVG_SIZE / 2); + let OUTER_R = $derived((SVG_SIZE / 2) * 0.9); + let innerR = $derived(donut ? OUTER_R * 0.55 : 0); + let containerClasses = $derived( [ 'chart-pie-container', @@ -272,13 +301,35 @@ SPDX-License-Identifier: MIT info: 'oklch(var(--in))' }; - let legendItemsWithColors = $derived( - processedData.map((s) => ({ - label: s.label, - color: VARIANT_COLORS[s.variant], - value: showValues ? String(s.value) : undefined, - ariaLabel: `${s.label}: ${s.value} (${s.percentage.toFixed(1)}%)` - })) + let legendItemsWithColors = $derived.by(() => + processedData + .filter((s) => { + // If hideLabeledFromLegend is true and labels are shown, + // exclude slices that have labels displayed on the chart + if (hideLabeledFromLegend && showLabels) { + return s.percentage < labelMinPercentage; + } + return true; + }) + .map((s) => ({ + label: hideLegendLabels ? '' : s.label, + color: VARIANT_COLORS[s.variant], + variant: s.variant, + value: showValues ? String(s.value) : undefined, + ariaLabel: `${s.label}: ${s.value} (${s.percentage.toFixed(1)}%)` + })) + ); + + // Legend size classes + let legendSize = $derived(size === 'sm' ? 'sm' : size === 'lg' ? 'lg' : 'md'); + let legendTextSize = $derived( + legendSize === 'sm' ? 'sm' : legendSize === 'md' ? 'base' : 'lg' + ) as 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl'; + let legendColorSize = $derived( + legendSize === 'sm' ? 'w-3 h-3' : legendSize === 'md' ? 'w-3.5 h-3.5' : 'w-4 h-4' + ); + let legendGap = $derived( + legendSize === 'sm' ? 'gap-1.5' : legendSize === 'md' ? 'gap-2' : 'gap-2.5' ); let accessibleDescription = $derived( @@ -395,26 +446,26 @@ SPDX-License-Identifier: MIT {/each} {#if showLabels} - {/if} From fb78611c182bdc1a026e0d6be9dcffe218f8d833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ay=C5=9Feturmusn?= Date: Sat, 31 Jan 2026 14:10:32 +0300 Subject: [PATCH 3/5] feat(ChartPie): implement pie chart with library components and AGENTS compliance --- .../admin/dashboard/ChartPie.stories.svelte | 127 +++-- .../admin/dashboard/ChartPie.svelte | 491 +++++++++++++----- 2 files changed, 444 insertions(+), 174 deletions(-) diff --git a/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte b/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte index 556f370f..81787bde 100644 --- a/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte +++ b/hexawebshare/src/components/admin/dashboard/ChartPie.stories.svelte @@ -72,6 +72,54 @@ SPDX-License-Identifier: MIT control: { type: 'number', min: 0, max: 50, step: 1 }, description: 'Minimum slice % to show label on chart (avoids overlap)' }, + sortBy: { + control: { type: 'select' }, + options: ['none', 'value-desc', 'value-asc', 'label'], + description: 'Sort data before rendering' + }, + groupSmallSlicesBelowPercentage: { + control: { type: 'number', min: 0, max: 30, step: 1 }, + description: 'Merge slices below this % into one "Other" slice' + }, + otherLabel: { + control: 'text', + description: 'Label for merged "Other" slice' + }, + otherSliceVariant: { + control: { type: 'select' }, + options: [ + 'primary', + 'secondary', + 'accent', + 'success', + 'warning', + 'error', + 'info', + 'neutral' + ], + description: 'Color variant for merged "Other" slice (info is more visible than neutral)' + }, + centerTitle: { + control: 'text', + description: 'Donut center title (e.g. Total)' + }, + innerRadiusRatio: { + control: { type: 'range', min: 0.3, max: 0.8, step: 0.05 }, + description: 'Donut hole size ratio' + }, + startAngle: { + control: { type: 'number', min: 0, max: 360, step: 15 }, + description: 'Starting angle in degrees' + }, + legendPosition: { + control: { type: 'select' }, + options: ['top', 'right', 'bottom', 'left'], + description: 'Legend position' + }, + hoverScale: { + control: { type: 'range', min: 1, max: 1.15, step: 0.01 }, + description: 'Hover scale effect' + }, loading: { control: 'boolean', description: 'Loading state' @@ -121,6 +169,15 @@ SPDX-License-Identifier: MIT useColoredBadges: true, hideLabeledFromLegend: false, labelMinPercentage: 15, + sortBy: 'none', + groupSmallSlicesBelowPercentage: undefined, + otherLabel: '', + otherSliceVariant: 'info', + centerTitle: '', + innerRadiusRatio: 0.55, + startAngle: 0, + legendPosition: 'bottom', + hoverScale: 1.05, loading: false, disabled: false, error: false @@ -174,13 +231,15 @@ SPDX-License-Identifier: MIT /> @@ -227,58 +286,10 @@ SPDX-License-Identifier: MIT }} /> - - - - - - - + string; + /** + * When donut is true, optional title shown in center (e.g. "Total"). Value is formatted with formatValue. + */ + centerTitle?: string; + /** + * Ratio of inner radius to outer radius when donut is true (0.5–0.8). Affects hole size. + * @default 0.55 + */ + innerRadiusRatio?: number; + /** + * Starting angle in degrees (0 = 12 o'clock, 90 = 3 o'clock). Chart draws clockwise from there. + * @default 0 + */ + startAngle?: number; + /** + * Format percentage for display (slice labels, tooltip, aria). Pass from app (e.g. Intl). + */ + formatPercentage?: (percentage: number) => string; + /** + * Legend position relative to chart. + * @default 'bottom' + */ + legendPosition?: 'top' | 'right' | 'bottom' | 'left'; + /** + * Scale factor for slice on hover (1 = no effect). Creates "explode" feel. + * @default 1.05 + */ + hoverScale?: number; + /** + * Called when a slice is clicked. + */ + onSliceClick?: (dataPoint: ChartPieDataPoint, index: number) => void; + /** + * Called when pointer enters a slice. + */ + onSliceHover?: (dataPoint: ChartPieDataPoint, index: number) => void; + /** + * Index of the selected slice (controlled). Use with onSelectedChange for two-way binding. + */ + selectedIndex?: number; + /** + * Called when selection changes (slice or legend click). + */ + onSelectedChange?: (index: number) => void; /** * Whether the chart is in loading state * @default false @@ -142,6 +216,26 @@ SPDX-License-Identifier: MIT * Accessible label for screen readers */ ariaLabel?: string; + /** + * Fallback for aria-label when ariaLabel is not set. Use {count} for data point count. + */ + ariaLabelFallback?: string; + /** + * Accessible description template when chart has data. Placeholders: {count}, {sliceOrSlices}, {items}, {total}. + */ + accessibleDescriptionTemplate?: string; + /** + * Word for one slice (used in accessible description when count is 1). Pass from i18n. + */ + sliceSingular?: string; + /** + * Word for multiple slices (used in accessible description when count > 1). Pass from i18n. + */ + slicePlural?: string; + /** + * Accessible label for the legend list (e.g. for i18n). + */ + legendAriaLabel?: string; /** * ID of element that labels this chart */ @@ -175,6 +269,21 @@ SPDX-License-Identifier: MIT useColoredBadges = true, hideLabeledFromLegend = false, labelMinPercentage = 15, + sortBy = 'none', + groupSmallSlicesBelowPercentage, + otherLabel = '', + otherSliceVariant = 'info', + formatValue = (v: number) => String(v), + centerTitle, + innerRadiusRatio = 0.55, + startAngle = 0, + formatPercentage = (p: number) => p.toFixed(0) + '%', + legendPosition = 'bottom', + hoverScale = 1.05, + onSliceClick, + onSliceHover, + selectedIndex, + onSelectedChange, loading = false, disabled = false, error = false, @@ -184,6 +293,11 @@ SPDX-License-Identifier: MIT errorTitle = 'Unable to load chart', errorDescription = 'Something went wrong while loading the chart data.', ariaLabel, + ariaLabelFallback = 'Pie chart showing {count} data points', + accessibleDescriptionTemplate = 'Pie chart with {count} {sliceOrSlices}. {items}. Total: {total}.', + sliceSingular = 'slice', + slicePlural = 'slices', + legendAriaLabel = 'Chart legend', ariaLabelledBy, class: className = '', ...props @@ -197,11 +311,10 @@ SPDX-License-Identifier: MIT let CX = $derived(SVG_SIZE / 2); let CY = $derived(SVG_SIZE / 2); let OUTER_R = $derived((SVG_SIZE / 2) * 0.9); - let innerR = $derived(donut ? OUTER_R * 0.55 : 0); + let innerR = $derived(donut ? OUTER_R * Math.min(0.8, Math.max(0.3, innerRadiusRatio)) : 0); let containerClasses = $derived( [ - 'chart-pie-container', 'w-full', 'flex flex-col items-center', disabled && 'opacity-50 cursor-not-allowed pointer-events-none', @@ -226,34 +339,63 @@ SPDX-License-Identifier: MIT let processedData = $derived.by((): ProcessedSlice[] => { if (!data?.length || total <= 0) return []; + const withVariant = data.map((d, i) => ({ + label: d.label, + value: Math.max(0, d.value), + variant: (colorScheme[i % colorScheme.length] ?? 'primary') as ChartPieVariant + })); + let sorted = withVariant; + if (sortBy === 'value-desc') { + sorted = [...withVariant].sort((a, b) => b.value - a.value); + } else if (sortBy === 'value-asc') { + sorted = [...withVariant].sort((a, b) => a.value - b.value); + } else if (sortBy === 'label') { + sorted = [...withVariant].sort((a, b) => a.label.localeCompare(b.label)); + } + const sortedTotal = sorted.reduce((s, d) => s + d.value, 0); + if (sortedTotal <= 0) return []; + const threshold = groupSmallSlicesBelowPercentage ?? 0; + const raw: ProcessedSlice[] = []; let acc = 0; - return data.map((d, i) => { - const v = Math.max(0, d.value); - const pct = total > 0 ? (v / total) * 100 : 0; + const small: ProcessedSlice[] = []; + for (const d of sorted) { + const pct = (d.value / sortedTotal) * 100; const start = acc / 100; acc += pct; const end = acc / 100; - const variant = colorScheme[i % colorScheme.length] ?? 'primary'; - return { - label: d.label, - value: v, - percentage: pct, - start, - end, - variant - }; - }); + const slice: ProcessedSlice = { ...d, percentage: pct, start, end }; + if (threshold > 0 && pct < threshold) { + small.push(slice); + } else { + raw.push(slice); + } + } + if (small.length > 0) { + const otherValue = small.reduce((s, x) => s + x.value, 0); + const otherPct = small.reduce((s, x) => s + x.percentage, 0); + const otherStart = raw.length > 0 ? raw[raw.length - 1].end : 0; + raw.push({ + label: otherLabel, + value: otherValue, + percentage: otherPct, + start: otherStart, + end: 1, + variant: otherSliceVariant + }); + } + return raw; }); - // Start at 12 o'clock (-90°), clockwise. angle 0 = top. + // t: 0-1 fraction of circle. startAngle in degrees (0 = 12 o'clock). Clockwise. + const angleOffsetRad = $derived((startAngle * Math.PI) / 180 - Math.PI / 2); function angleToCoord(t: number): [number, number] { - const rad = t * 2 * Math.PI - Math.PI / 2; + const rad = t * 2 * Math.PI + angleOffsetRad; return [CX + OUTER_R * Math.cos(rad), CY + OUTER_R * Math.sin(rad)]; } function innerCoord(t: number): [number, number] { if (innerR <= 0) return [CX, CY]; - const rad = t * 2 * Math.PI - Math.PI / 2; + const rad = t * 2 * Math.PI + angleOffsetRad; return [CX + innerR * Math.cos(rad), CY + innerR * Math.sin(rad)]; } @@ -273,7 +415,7 @@ SPDX-License-Identifier: MIT function labelPos(s: ProcessedSlice): [number, number] { const t = (s.start + s.end) / 2; const r = donut ? (innerR + OUTER_R) / 2 : (OUTER_R * 2) / 3; - const rad = t * 2 * Math.PI - Math.PI / 2; + const rad = t * 2 * Math.PI + angleOffsetRad; return [CX + r * Math.cos(rad), CY + r * Math.sin(rad)]; } @@ -285,38 +427,25 @@ SPDX-License-Identifier: MIT success: 'fill-success', warning: 'fill-warning', error: 'fill-error', - info: 'fill-info' - }; - - // ChartLegend expects `color` as hex/css. We use DaisyUI variants. - // ChartLegend uses `style="background-color: {item.color}"`. We need actual colors. - // Use a small map of variant -> DaisyUI semantic color. Legend color swatch. - const VARIANT_COLORS: Record = { - primary: 'oklch(var(--p))', - secondary: 'oklch(var(--s))', - accent: 'oklch(var(--a))', - success: 'oklch(var(--su))', - warning: 'oklch(var(--wa))', - error: 'oklch(var(--er))', - info: 'oklch(var(--in))' + info: 'fill-info', + neutral: 'fill-neutral' }; let legendItemsWithColors = $derived.by(() => processedData - .filter((s) => { - // If hideLabeledFromLegend is true and labels are shown, - // exclude slices that have labels displayed on the chart + .map((s, sliceIndex) => ({ s, sliceIndex })) + .filter(({ s }) => { if (hideLabeledFromLegend && showLabels) { return s.percentage < labelMinPercentage; } return true; }) - .map((s) => ({ + .map(({ s, sliceIndex }) => ({ label: hideLegendLabels ? '' : s.label, - color: VARIANT_COLORS[s.variant], variant: s.variant, - value: showValues ? String(s.value) : undefined, - ariaLabel: `${s.label}: ${s.value} (${s.percentage.toFixed(1)}%)` + value: showValues ? formatValue(s.value) : undefined, + ariaLabel: `${s.label}: ${formatValue(s.value)} (${formatPercentage(s.percentage)})`, + sliceIndex })) ); @@ -325,25 +454,33 @@ SPDX-License-Identifier: MIT let legendTextSize = $derived( legendSize === 'sm' ? 'sm' : legendSize === 'md' ? 'base' : 'lg' ) as 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl'; - let legendColorSize = $derived( - legendSize === 'sm' ? 'w-3 h-3' : legendSize === 'md' ? 'w-3.5 h-3.5' : 'w-4 h-4' - ); + let legendDotSize = $derived(legendSize === 'sm' ? 'sm' : legendSize === 'md' ? 'md' : 'lg') as + | 'xs' + | 'sm' + | 'md' + | 'lg' + | 'xl'; let legendGap = $derived( legendSize === 'sm' ? 'gap-1.5' : legendSize === 'md' ? 'gap-2' : 'gap-2.5' ); let accessibleDescription = $derived( - data?.length - ? `Pie chart with ${data.length} ${data.length === 1 ? 'slice' : 'slices'}. ` + - processedData - .map((s) => `${s.label}: ${s.value} (${s.percentage.toFixed(1)}%)`) - .join('. ') + - `. Total: ${total}.` - : 'No data available in chart.' + data?.length && total > 0 + ? accessibleDescriptionTemplate + .replace('{count}', String(data.length)) + .replace('{sliceOrSlices}', data.length === 1 ? sliceSingular : slicePlural) + .replace( + '{items}', + processedData + .map((s) => `${s.label}: ${formatValue(s.value)} (${formatPercentage(s.percentage)})`) + .join('. ') + ) + .replace('{total}', formatValue(total)) + : emptyTitle ); let defaultAriaLabel = $derived( - ariaLabel ?? `Pie chart showing ${data?.length ?? 0} data points` + ariaLabel ?? ariaLabelFallback.replace('{count}', String(data?.length ?? 0)) ); let hasData = $derived(Boolean(data?.length && total > 0)); @@ -359,19 +496,22 @@ SPDX-License-Identifier: MIT aria-describedby="chart-pie-description" {...props} > +
{accessibleDescription}
{#if loading} - -
-
+ {:else if error} - -
-
+ {:else if !hasData} - -
-
+ {:else} +
-
+ +
+ {#if donut && centerTitle} + + + {/if} {#if showLabels} +