animot-presenter 0.5.11 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,135 +1,167 @@
1
1
  <script lang="ts">
2
+ import type { ChartElement, ChartSeries, ChartDataPoint } from '../types';
2
3
  import { onMount } from 'svelte';
3
- import type { ChartElement } from '../types';
4
+ import { normalizeSeries, resolveColor, formatValue, computeYRange } from '../utils/chart';
4
5
 
5
- interface Props { element: ChartElement; slideId: string; }
6
- let { element, slideId }: Props = $props();
6
+ interface Props {
7
+ element: ChartElement;
8
+ slideId: string;
9
+ /** Optional snapshot of the same chart on the OUTGOING slide. When set,
10
+ * the renderer tweens data values from prior state to current. */
11
+ previousElement?: ChartElement | null;
12
+ }
13
+ let { element, slideId, previousElement = null }: Props = $props();
7
14
 
8
15
  let animationProgress = $state(1);
16
+ let morphProgress = $state(1);
9
17
  let animationFrame: number | null = null;
10
18
 
11
- function animateChart() {
12
- const startTime = performance.now();
13
- const duration = element.animationDuration;
19
+ function ease(t: number) { return 1 - Math.pow(1 - t, 3); }
14
20
 
21
+ function animate(setter: (v: number) => void, duration: number, delay = 0) {
22
+ if (animationFrame) cancelAnimationFrame(animationFrame);
23
+ const start = performance.now() + delay;
24
+ setter(0);
15
25
  function tick() {
16
- const elapsed = performance.now() - startTime;
17
- const progress = Math.min(elapsed / duration, 1);
18
- // Ease out cubic
19
- const eased = 1 - Math.pow(1 - progress, 3);
20
- animationProgress = eased;
21
-
22
- if (progress < 1) {
23
- animationFrame = requestAnimationFrame(tick);
24
- }
26
+ const now = performance.now();
27
+ if (now < start) { animationFrame = requestAnimationFrame(tick); return; }
28
+ const p = Math.min((now - start) / duration, 1);
29
+ setter(ease(p));
30
+ if (p < 1) animationFrame = requestAnimationFrame(tick);
25
31
  }
32
+ animationFrame = requestAnimationFrame(tick);
33
+ }
26
34
 
27
- // Cancel any existing animation
28
- if (animationFrame) cancelAnimationFrame(animationFrame);
29
-
30
- // Start animation after a short delay
31
- setTimeout(() => {
32
- animationProgress = 0;
33
- animationFrame = requestAnimationFrame(tick);
34
- }, 200);
35
+ function runAnimation() {
36
+ if (previousElement) {
37
+ animationProgress = 1;
38
+ animate((v) => (morphProgress = v), element.animationDuration);
39
+ } else {
40
+ morphProgress = 1;
41
+ animate((v) => (animationProgress = v), element.animationDuration, 200);
42
+ }
35
43
  }
36
44
 
37
- // Trigger animation on mount
38
45
  onMount(() => {
39
- animateChart();
40
- return () => {
41
- if (animationFrame) cancelAnimationFrame(animationFrame);
42
- };
46
+ runAnimation();
47
+ return () => { if (animationFrame) cancelAnimationFrame(animationFrame); };
43
48
  });
44
49
 
45
- // Also trigger when slideId changes
46
- $effect(() => {
47
- if (slideId) {
48
- animateChart();
49
- }
50
- });
50
+ $effect(() => { if (slideId) runAnimation(); });
51
51
 
52
- // Calculate max value for scaling
53
- const maxValue = $derived(Math.max(...element.data.map(d => d.value), 1));
52
+ const seriesList = $derived(normalizeSeries(element));
53
+ const prevSeriesList = $derived(previousElement ? normalizeSeries(previousElement) : null);
54
+ const isMultiSeries = $derived((element.series?.length ?? 0) > 1);
55
+ const barLayout = $derived(element.barLayout ?? 'grouped');
56
+ const stagger = $derived(element.revealStagger ?? 0);
54
57
 
55
- // Calculate line/area chart points
56
- const linePoints = $derived.by(() => {
57
- return element.data.map((d, i) => {
58
- const x = 10 + (i / Math.max(element.data.length - 1, 1)) * 80;
59
- const y = 90 - (d.value / maxValue) * 80 * animationProgress;
60
- return `${x},${y}`;
61
- }).join(' ');
62
- });
63
-
64
- // Get color for data point
65
- function getColor(index: number, customColor?: string): string {
66
- if (customColor) return customColor;
67
- return element.colors[index % element.colors.length];
58
+ function effectiveValue(sIdx: number, pIdx: number): number {
59
+ const target = seriesList[sIdx]?.data[pIdx]?.value ?? 0;
60
+ if (prevSeriesList) {
61
+ const prevSeries =
62
+ prevSeriesList.find((s) => s.id === seriesList[sIdx]?.id) ?? prevSeriesList[sIdx];
63
+ const targetLabel = seriesList[sIdx]?.data[pIdx]?.label;
64
+ const prevPoint =
65
+ prevSeries?.data.find((p) => p.label === targetLabel) ?? prevSeries?.data[pIdx];
66
+ const prev = prevPoint?.value ?? target;
67
+ return prev + (target - prev) * morphProgress;
68
+ }
69
+ const totalPoints = seriesList[0]?.data.length ?? 1;
70
+ if (stagger > 0 && element.animationDuration > 0) {
71
+ const slice = stagger / element.animationDuration;
72
+ const localStart = pIdx * slice;
73
+ const localEnd = Math.min(1, localStart + (1 - slice * (totalPoints - 1)));
74
+ const span = Math.max(0.0001, localEnd - localStart);
75
+ const local = Math.min(1, Math.max(0, (animationProgress - localStart) / span));
76
+ return target * local;
77
+ }
78
+ return target * animationProgress;
68
79
  }
69
80
 
70
- // Calculate pie/donut segments
71
- const pieSegments = $derived.by(() => {
72
- const total = element.data.reduce((sum, d) => sum + d.value, 0);
73
- let currentAngle = -90; // Start from top
81
+ const yRange = $derived(computeYRange(element));
82
+ const yTop = 10;
83
+ const yBottom = 90;
84
+ function yFor(value: number): number {
85
+ const { min, max } = yRange;
86
+ const frac = (value - min) / (max - min);
87
+ return yBottom - frac * (yBottom - yTop);
88
+ }
89
+ const zeroY = $derived(yFor(0));
74
90
 
75
- return element.data.map((d, i) => {
76
- const percentage = d.value / total;
77
- const angle = percentage * 360 * animationProgress;
78
- const startAngle = currentAngle;
79
- currentAngle += angle;
91
+ const labelCount = $derived(seriesList[0]?.data.length ?? 0);
92
+ const slotWidth = $derived(labelCount > 0 ? 80 / labelCount : 80);
93
+ const slotPad = $derived(slotWidth * 0.15);
94
+ function barSlotX(pIdx: number): number { return 10 + pIdx * slotWidth + slotPad; }
95
+ function barSlotInnerWidth(): number { return slotWidth - slotPad * 2; }
80
96
 
81
- return {
82
- ...d,
83
- startAngle,
84
- endAngle: currentAngle,
85
- percentage,
86
- color: getColor(i, d.color)
87
- };
97
+ const pieSeries = $derived(seriesList[0]);
98
+ const pieSegments = $derived.by(() => {
99
+ const data = pieSeries?.data ?? [];
100
+ const animated = data.map((_, i) => effectiveValue(0, i));
101
+ const total = animated.reduce((s, v) => s + Math.max(0, v), 0) || 1;
102
+ let cur = -90;
103
+ return data.map((d, i) => {
104
+ const v = Math.max(0, animated[i]);
105
+ const pct = v / total;
106
+ const start = cur;
107
+ cur += pct * 360;
108
+ return { point: d, index: i, startAngle: start, endAngle: cur, percentage: pct };
88
109
  });
89
110
  });
90
111
 
91
- // SVG arc path for pie
92
112
  function describeArc(cx: number, cy: number, r: number, startAngle: number, endAngle: number): string {
113
+ if (endAngle - startAngle <= 0.01) return '';
93
114
  const start = polarToCartesian(cx, cy, r, endAngle);
94
115
  const end = polarToCartesian(cx, cy, r, startAngle);
95
116
  const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1;
96
-
97
- return [
98
- 'M', cx, cy,
99
- 'L', start.x, start.y,
100
- 'A', r, r, 0, largeArcFlag, 0, end.x, end.y,
101
- 'Z'
102
- ].join(' ');
117
+ return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y} Z`;
103
118
  }
104
-
105
- // SVG arc path for donut
106
119
  function describeDonutArc(cx: number, cy: number, outerR: number, innerR: number, startAngle: number, endAngle: number): string {
120
+ if (endAngle - startAngle <= 0.01) return '';
107
121
  const outerStart = polarToCartesian(cx, cy, outerR, endAngle);
108
122
  const outerEnd = polarToCartesian(cx, cy, outerR, startAngle);
109
123
  const innerStart = polarToCartesian(cx, cy, innerR, endAngle);
110
124
  const innerEnd = polarToCartesian(cx, cy, innerR, startAngle);
111
125
  const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1;
112
-
113
- return [
114
- 'M', outerStart.x, outerStart.y,
115
- 'A', outerR, outerR, 0, largeArcFlag, 0, outerEnd.x, outerEnd.y,
116
- 'L', innerEnd.x, innerEnd.y,
117
- 'A', innerR, innerR, 0, largeArcFlag, 1, innerStart.x, innerStart.y,
118
- 'Z'
119
- ].join(' ');
126
+ return `M ${outerStart.x} ${outerStart.y} A ${outerR} ${outerR} 0 ${largeArcFlag} 0 ${outerEnd.x} ${outerEnd.y} L ${innerEnd.x} ${innerEnd.y} A ${innerR} ${innerR} 0 ${largeArcFlag} 1 ${innerStart.x} ${innerStart.y} Z`;
120
127
  }
121
-
122
128
  function polarToCartesian(cx: number, cy: number, r: number, angle: number) {
123
129
  const rad = (angle * Math.PI) / 180;
124
- return {
125
- x: cx + r * Math.cos(rad),
126
- y: cy + r * Math.sin(rad)
127
- };
130
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
128
131
  }
132
+
133
+ const stackedBars = $derived.by(() => {
134
+ if (!isMultiSeries || barLayout !== 'stacked' || element.chartType !== 'bar') return [];
135
+ const labels = seriesList[0]?.data ?? [];
136
+ return labels.map((basePoint, pIdx) => {
137
+ let posOffset = 0;
138
+ let negOffset = 0;
139
+ const segments = seriesList.map((series, sIdx) => {
140
+ const v = effectiveValue(sIdx, pIdx);
141
+ const isPos = v >= 0;
142
+ const fromOffset = isPos ? posOffset : negOffset;
143
+ const toOffset = fromOffset + v;
144
+ if (isPos) posOffset = toOffset; else negOffset = toOffset;
145
+ const yStart = yFor(fromOffset);
146
+ const yEnd = yFor(toOffset);
147
+ return { sIdx, series, value: v, y: Math.min(yStart, yEnd), h: Math.abs(yStart - yEnd) };
148
+ });
149
+ return { basePoint, pIdx, segments };
150
+ });
151
+ });
152
+
153
+ const yTicks = $derived.by(() => {
154
+ if (!element.yAxis?.showLabels) return [];
155
+ const { min, max } = yRange;
156
+ const steps = 4;
157
+ return Array.from({ length: steps + 1 }, (_, i) => {
158
+ const v = min + (i / steps) * (max - min);
159
+ return { value: v, y: yFor(v) };
160
+ });
161
+ });
129
162
  </script>
130
163
 
131
- <div
132
- class="chart"
164
+ <div class="animot-chart"
133
165
  style:background-color={element.backgroundColor}
134
166
  style:padding="{element.padding}px"
135
167
  style:border-radius="{element.borderRadius}px"
@@ -137,180 +169,180 @@
137
169
  style:font-size="{element.fontSize}px"
138
170
  >
139
171
  {#if element.title}
140
- <div class="chart-title">{element.title}</div>
172
+ <div class="animot-chart-title">{element.title}</div>
141
173
  {/if}
142
174
 
143
- <div class="chart-content">
175
+ <div class="animot-chart-content">
144
176
  {#if element.chartType === 'bar'}
145
- <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="bar-chart">
177
+ <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="animot-bar-chart">
146
178
  {#if element.showGrid}
147
- {#each [0, 25, 50, 75, 100] as y}
148
- <line x1="0" y1={y} x2="100" y2={y} stroke="currentColor" stroke-opacity="0.1" stroke-width="0.5" />
179
+ {#each [0, 25, 50, 75, 100] as gy}
180
+ <line x1="0" y1={gy} x2="100" y2={gy} stroke="currentColor" stroke-opacity="0.1" stroke-width="0.5" />
149
181
  {/each}
150
182
  {/if}
151
- {#each element.data as point, i}
152
- {@const barWidth = 80 / element.data.length}
153
- {@const barX = 10 + i * (barWidth + 5)}
154
- {@const barHeight = (point.value / maxValue) * 80 * animationProgress}
155
- <rect
156
- x={barX}
157
- y={90 - barHeight}
158
- width={barWidth}
159
- height={barHeight}
160
- fill={getColor(i, point.color)}
161
- rx="1"
162
- />
163
- {#if element.showLabels}
164
- <text
165
- x={barX + barWidth / 2}
166
- y="98"
167
- text-anchor="middle"
168
- font-size="4"
169
- fill="currentColor"
170
- >{point.label}</text>
171
- {/if}
172
- {#if element.showValues}
173
- <text
174
- x={barX + barWidth / 2}
175
- y={88 - barHeight}
176
- text-anchor="middle"
177
- font-size="3.5"
178
- fill="currentColor"
179
- >{Math.round(point.value * animationProgress)}</text>
180
- {/if}
183
+ {#if yRange.min < 0}
184
+ <line x1="0" y1={zeroY} x2="100" y2={zeroY} stroke="currentColor" stroke-opacity="0.3" stroke-width="0.5" />
185
+ {/if}
186
+
187
+ {#if isMultiSeries && barLayout === 'stacked'}
188
+ {#each stackedBars as bar}
189
+ {@const slotX = barSlotX(bar.pIdx)}
190
+ {@const innerW = barSlotInnerWidth()}
191
+ {#each bar.segments as seg}
192
+ {#if seg.h > 0.01}
193
+ <rect x={slotX} y={seg.y} width={innerW} height={seg.h}
194
+ fill={resolveColor(element, seg.sIdx, bar.pIdx, seg.series.data[bar.pIdx], seg.series)} />
195
+ {/if}
196
+ {/each}
197
+ {#if element.showLabels}
198
+ <text x={slotX + innerW / 2} y="98" text-anchor="middle" font-size="4" fill="currentColor">{bar.basePoint.label}</text>
199
+ {/if}
200
+ {/each}
201
+ {:else}
202
+ {#each seriesList[0].data as basePoint, pIdx}
203
+ {@const slotX = barSlotX(pIdx)}
204
+ {@const innerW = barSlotInnerWidth()}
205
+ {@const seriesCount = seriesList.length}
206
+ {@const groupGap = innerW * 0.05}
207
+ {@const barW = (innerW - groupGap * (seriesCount - 1)) / seriesCount}
208
+ {#each seriesList as series, sIdx}
209
+ {@const v = effectiveValue(sIdx, pIdx)}
210
+ {@const yStart = zeroY}
211
+ {@const yEnd = yFor(v)}
212
+ {@const barY = Math.min(yStart, yEnd)}
213
+ {@const barH = Math.abs(yStart - yEnd)}
214
+ <rect x={slotX + sIdx * (barW + groupGap)} y={barY} width={barW} height={barH}
215
+ fill={resolveColor(element, sIdx, pIdx, series.data[pIdx], series)} rx="0.5" />
216
+ {#if element.showValues}
217
+ <text x={slotX + sIdx * (barW + groupGap) + barW / 2}
218
+ y={v >= 0 ? barY - 1 : barY + barH + 3.5}
219
+ text-anchor="middle" font-size="3" fill="currentColor">{formatValue(v, element.valueFormat)}</text>
220
+ {/if}
221
+ {/each}
222
+ {#if element.showLabels}
223
+ <text x={slotX + innerW / 2} y="98" text-anchor="middle" font-size="4" fill="currentColor">{basePoint.label}</text>
224
+ {/if}
225
+ {/each}
226
+ {/if}
227
+
228
+ {#each yTicks as t}
229
+ <text x="1" y={t.y + 1} font-size="3" fill="currentColor" opacity="0.7">{formatValue(t.value, element.valueFormat)}</text>
181
230
  {/each}
182
231
  </svg>
232
+
183
233
  {:else if element.chartType === 'line' || element.chartType === 'area'}
184
- <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="line-chart">
234
+ <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="animot-line-chart">
185
235
  {#if element.showGrid}
186
- {#each [0, 25, 50, 75, 100] as y}
187
- <line x1="0" y1={y} x2="100" y2={y} stroke="currentColor" stroke-opacity="0.1" stroke-width="0.5" />
188
- {/each}
189
- {#each element.data as _, i}
190
- {@const x = 10 + (i / (element.data.length - 1)) * 80}
191
- <line x1={x} y1="10" x2={x} y2="90" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.5" />
236
+ {#each [0, 25, 50, 75, 100] as gy}
237
+ <line x1="0" y1={gy} x2="100" y2={gy} stroke="currentColor" stroke-opacity="0.1" stroke-width="0.5" />
192
238
  {/each}
193
239
  {/if}
194
-
195
- {#if element.chartType === 'area'}
196
- <polygon
197
- points="{linePoints} {10 + 80},90 10,90"
198
- fill={element.colors[0]}
199
- fill-opacity="0.3"
200
- />
240
+ {#if yRange.min < 0}
241
+ <line x1="10" y1={zeroY} x2="90" y2={zeroY} stroke="currentColor" stroke-opacity="0.3" stroke-width="0.5" />
201
242
  {/if}
202
243
 
203
- <polyline
204
- points={linePoints}
205
- fill="none"
206
- stroke={element.colors[0]}
207
- stroke-width="2"
208
- stroke-linecap="round"
209
- stroke-linejoin="round"
210
- />
244
+ {#each seriesList as series, sIdx}
245
+ {@const pts = series.data.map((_, i) => {
246
+ const x = 10 + (i / Math.max(series.data.length - 1, 1)) * 80;
247
+ const y = yFor(effectiveValue(sIdx, i));
248
+ return `${x},${y}`;
249
+ }).join(' ')}
250
+ {@const seriesColor = resolveColor(element, sIdx, 0, undefined, series)}
251
+ {#if element.chartType === 'area'}
252
+ {@const areaPath = `${pts} 90,${zeroY} 10,${zeroY}`}
253
+ <polygon points={areaPath} fill={seriesColor} fill-opacity={isMultiSeries ? 0.18 : 0.3} />
254
+ {/if}
255
+ <polyline points={pts} fill="none" stroke={seriesColor} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
256
+ {#each series.data as point, pIdx}
257
+ {@const x = 10 + (pIdx / Math.max(series.data.length - 1, 1)) * 80}
258
+ {@const y = yFor(effectiveValue(sIdx, pIdx))}
259
+ <circle cx={x} cy={y} r="1.4" fill={resolveColor(element, sIdx, pIdx, point, series)} />
260
+ {#if element.showValues}
261
+ <text x={x} y={y - 2.5} text-anchor="middle" font-size="3" fill="currentColor">{formatValue(effectiveValue(sIdx, pIdx), element.valueFormat)}</text>
262
+ {/if}
263
+ {/each}
264
+ {/each}
211
265
 
212
- {#each element.data as point, i}
213
- {@const x = 10 + (i / Math.max(element.data.length - 1, 1)) * 80}
214
- {@const y = 90 - (point.value / maxValue) * 80 * animationProgress}
215
- <circle cx={x} cy={y} r="2" fill={getColor(i, point.color) || element.colors[0]} />
216
- {#if element.showLabels}
266
+ {#if element.showLabels}
267
+ {#each seriesList[0]?.data ?? [] as point, pIdx}
268
+ {@const x = 10 + (pIdx / Math.max((seriesList[0]?.data.length ?? 1) - 1, 1)) * 80}
217
269
  <text x={x} y="98" text-anchor="middle" font-size="4" fill="currentColor">{point.label}</text>
218
- {/if}
219
- {#if element.showValues}
220
- <text x={x} y={y - 4} text-anchor="middle" font-size="3.5" fill="currentColor">{Math.round(point.value * animationProgress)}</text>
221
- {/if}
270
+ {/each}
271
+ {/if}
272
+
273
+ {#each yTicks as t}
274
+ <text x="1" y={t.y + 1} font-size="3" fill="currentColor" opacity="0.7">{formatValue(t.value, element.valueFormat)}</text>
222
275
  {/each}
223
276
  </svg>
277
+
224
278
  {:else if element.chartType === 'pie'}
225
- <svg viewBox="0 0 100 100" class="pie-chart">
226
- {#each pieSegments as segment, i}
227
- {#if segment.endAngle - segment.startAngle > 0.1}
228
- {@const midAngle = (segment.startAngle + segment.endAngle) / 2}
279
+ <svg viewBox="0 0 100 100" class="animot-pie-chart">
280
+ {#each pieSegments as seg}
281
+ {#if seg.endAngle - seg.startAngle > 0.1}
282
+ {@const midAngle = (seg.startAngle + seg.endAngle) / 2}
229
283
  {@const valuePos = polarToCartesian(50, 50, 28, midAngle)}
230
284
  {@const labelPos = polarToCartesian(50, 50, 48, midAngle)}
231
285
  {@const textAnchor = midAngle > -90 && midAngle < 90 ? 'start' : 'end'}
232
- <path
233
- d={describeArc(50, 50, 40, segment.startAngle, segment.endAngle)}
234
- fill={segment.color}
235
- />
286
+ {@const fill = resolveColor(element, 0, seg.index, seg.point, pieSeries)}
287
+ <path d={describeArc(50, 50, 40, seg.startAngle, seg.endAngle)} {fill} />
236
288
  {#if element.showValues}
237
- <text
238
- x={valuePos.x}
239
- y={valuePos.y}
240
- text-anchor="middle"
241
- dominant-baseline="middle"
242
- font-size="4"
243
- fill="white"
244
- font-weight="600"
245
- >{Math.round(segment.percentage * 100)}%</text>
289
+ <text x={valuePos.x} y={valuePos.y} text-anchor="middle" dominant-baseline="middle" font-size="4" fill="white" font-weight="600">{Math.round(seg.percentage * 100)}%</text>
246
290
  {/if}
247
291
  {#if element.showLabels}
248
- <text
249
- x={labelPos.x}
250
- y={labelPos.y}
251
- text-anchor={textAnchor}
252
- dominant-baseline="middle"
253
- font-size="3.5"
254
- fill="currentColor"
255
- >{element.data[i].label}</text>
292
+ <text x={labelPos.x} y={labelPos.y} text-anchor={textAnchor} dominant-baseline="middle" font-size="3.5" fill="currentColor">{seg.point.label}</text>
256
293
  {/if}
257
294
  {/if}
258
295
  {/each}
259
- {#if element.showLegend}
260
- {#each element.data as point, i}
261
- <rect x="85" y={10 + i * 8} width="4" height="4" fill={getColor(i, point.color)} />
296
+ {#if element.showLegend && pieSeries}
297
+ {#each pieSeries.data as point, i}
298
+ <rect x="85" y={10 + i * 8} width="4" height="4" fill={resolveColor(element, 0, i, point, pieSeries)} />
262
299
  <text x="91" y={14 + i * 8} font-size="4" fill="currentColor">{point.label}</text>
263
300
  {/each}
264
301
  {/if}
265
302
  </svg>
303
+
266
304
  {:else if element.chartType === 'donut'}
267
- <svg viewBox="0 0 100 100" class="donut-chart">
268
- {#each pieSegments as segment, i}
269
- {#if segment.endAngle - segment.startAngle > 0.1}
270
- {@const midAngle = (segment.startAngle + segment.endAngle) / 2}
305
+ <svg viewBox="0 0 100 100" class="animot-donut-chart">
306
+ {#each pieSegments as seg}
307
+ {#if seg.endAngle - seg.startAngle > 0.1}
308
+ {@const midAngle = (seg.startAngle + seg.endAngle) / 2}
271
309
  {@const valuePos = polarToCartesian(50, 50, 32.5, midAngle)}
272
310
  {@const labelPos = polarToCartesian(50, 50, 48, midAngle)}
273
311
  {@const textAnchor = midAngle > -90 && midAngle < 90 ? 'start' : 'end'}
274
- <path
275
- d={describeDonutArc(50, 50, 40, 25, segment.startAngle, segment.endAngle)}
276
- fill={segment.color}
277
- />
312
+ {@const fill = resolveColor(element, 0, seg.index, seg.point, pieSeries)}
313
+ <path d={describeDonutArc(50, 50, 40, 25, seg.startAngle, seg.endAngle)} {fill} />
278
314
  {#if element.showValues}
279
- <text
280
- x={valuePos.x}
281
- y={valuePos.y}
282
- text-anchor="middle"
283
- dominant-baseline="middle"
284
- font-size="3.5"
285
- fill="white"
286
- font-weight="600"
287
- >{Math.round(segment.percentage * 100)}%</text>
315
+ <text x={valuePos.x} y={valuePos.y} text-anchor="middle" dominant-baseline="middle" font-size="3.5" fill="white" font-weight="600">{Math.round(seg.percentage * 100)}%</text>
288
316
  {/if}
289
317
  {#if element.showLabels}
290
- <text
291
- x={labelPos.x}
292
- y={labelPos.y}
293
- text-anchor={textAnchor}
294
- dominant-baseline="middle"
295
- font-size="3.5"
296
- fill="currentColor"
297
- >{element.data[i].label}</text>
318
+ <text x={labelPos.x} y={labelPos.y} text-anchor={textAnchor} dominant-baseline="middle" font-size="3.5" fill="currentColor">{seg.point.label}</text>
298
319
  {/if}
299
320
  {/if}
300
321
  {/each}
301
- {#if element.showLegend}
302
- {#each element.data as point, i}
303
- <rect x="85" y={10 + i * 8} width="4" height="4" fill={getColor(i, point.color)} />
322
+ {#if element.showLegend && pieSeries}
323
+ {#each pieSeries.data as point, i}
324
+ <rect x="85" y={10 + i * 8} width="4" height="4" fill={resolveColor(element, 0, i, point, pieSeries)} />
304
325
  <text x="91" y={14 + i * 8} font-size="4" fill="currentColor">{point.label}</text>
305
326
  {/each}
306
327
  {/if}
307
328
  </svg>
308
329
  {/if}
330
+
331
+ {#if element.showLegend && isMultiSeries && (element.chartType === 'bar' || element.chartType === 'line' || element.chartType === 'area')}
332
+ <div class="animot-series-legend">
333
+ {#each seriesList as series, sIdx}
334
+ <span class="animot-legend-item">
335
+ <span class="animot-swatch" style:background={resolveColor(element, sIdx, 0, undefined, series)}></span>
336
+ {series.name || `Series ${sIdx + 1}`}
337
+ </span>
338
+ {/each}
339
+ </div>
340
+ {/if}
309
341
  </div>
310
342
  </div>
311
343
 
312
344
  <style>
313
- .chart {
345
+ .animot-chart {
314
346
  width: 100%;
315
347
  height: 100%;
316
348
  display: flex;
@@ -318,24 +350,14 @@
318
350
  box-sizing: border-box;
319
351
  overflow: hidden;
320
352
  }
321
-
322
- .chart-title {
323
- font-weight: 600;
324
- text-align: center;
325
- margin-bottom: 8px;
326
- }
327
-
328
- .chart-content {
329
- flex: 1;
330
- min-height: 0;
331
- }
332
-
333
- .chart-content svg {
334
- width: 100%;
335
- height: 100%;
336
- }
337
-
338
- .bar-chart, .line-chart {
339
- overflow: visible;
353
+ .animot-chart-title { font-weight: 600; text-align: center; margin-bottom: 8px; }
354
+ .animot-chart-content { flex: 1; min-height: 0; display: flex; flex-direction: column; }
355
+ .animot-chart-content svg { width: 100%; flex: 1; min-height: 0; }
356
+ .animot-bar-chart, .animot-line-chart { overflow: visible; }
357
+ .animot-series-legend {
358
+ display: flex; flex-wrap: wrap; gap: 12px;
359
+ justify-content: center; padding: 6px 0 0; font-size: 0.85em;
340
360
  }
361
+ .animot-legend-item { display: inline-flex; align-items: center; gap: 5px; }
362
+ .animot-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
341
363
  </style>
@@ -2,6 +2,9 @@ import type { ChartElement } from '../types';
2
2
  interface Props {
3
3
  element: ChartElement;
4
4
  slideId: string;
5
+ /** Optional snapshot of the same chart on the OUTGOING slide. When set,
6
+ * the renderer tweens data values from prior state to current. */
7
+ previousElement?: ChartElement | null;
5
8
  }
6
9
  declare const ChartRenderer: import("svelte").Component<Props, {}, "">;
7
10
  type ChartRenderer = ReturnType<typeof ChartRenderer>;
package/dist/types.d.ts CHANGED
@@ -303,10 +303,29 @@ export interface ChartDataPoint {
303
303
  value: number;
304
304
  color?: string;
305
305
  }
306
+ export interface ChartSeries {
307
+ id: string;
308
+ name: string;
309
+ data: ChartDataPoint[];
310
+ color?: string;
311
+ }
312
+ export type ChartBarLayout = 'grouped' | 'stacked';
313
+ export interface ChartValueFormat {
314
+ prefix?: string;
315
+ suffix?: string;
316
+ decimals?: number;
317
+ abbreviate?: boolean;
318
+ }
319
+ export interface ChartYAxis {
320
+ min?: number;
321
+ max?: number;
322
+ showLabels?: boolean;
323
+ }
306
324
  export interface ChartElement extends BaseElement {
307
325
  type: 'chart';
308
326
  chartType: ChartType;
309
327
  data: ChartDataPoint[];
328
+ series?: ChartSeries[];
310
329
  title: string;
311
330
  showLabels: boolean;
312
331
  showValues: boolean;
@@ -314,11 +333,16 @@ export interface ChartElement extends BaseElement {
314
333
  showLegend: boolean;
315
334
  animationDuration: number;
316
335
  colors: string[];
336
+ useKitPalette?: boolean;
317
337
  backgroundColor: string;
318
338
  textColor: string;
319
339
  fontSize: number;
320
340
  padding: number;
321
341
  borderRadius: number;
342
+ barLayout?: ChartBarLayout;
343
+ revealStagger?: number;
344
+ valueFormat?: ChartValueFormat;
345
+ yAxis?: ChartYAxis;
322
346
  }
323
347
  export interface IconElement extends BaseElement {
324
348
  type: 'icon';