@cfasim-ui/charts 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/charts",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "Chart visualization components for cfasim-ui",
6
6
  "license": "Apache-2.0",
@@ -25,7 +25,7 @@
25
25
  "reka-ui": "^2.9.2",
26
26
  "topojson-client": "^3.1.0",
27
27
  "us-atlas": "^3.0.1",
28
- "@cfasim-ui/shared": "0.1.7"
28
+ "@cfasim-ui/shared": "0.1.9"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "vue": "^3.5.0"
@@ -38,7 +38,7 @@
38
38
  "@types/topojson-specification": "^1.0.5",
39
39
  "@vitejs/plugin-vue": "^6.0.5",
40
40
  "@vue/test-utils": "^2.4.6",
41
- "happy-dom": "^20.8.8",
41
+ "happy-dom": "^20.8.9",
42
42
  "vitest": "^4.1.0"
43
43
  }
44
44
  }
@@ -125,6 +125,48 @@ A responsive SVG line chart with support for multiple series, axis labels, and c
125
125
  </template>
126
126
  </ComponentDemo>
127
127
 
128
+ ### Grid lines
129
+
130
+ <ComponentDemo>
131
+ <LineChart
132
+ :series="[
133
+ { data: [0, 10, 25, 45, 60, 55, 40, 20, 8], color: '#fb7e38', strokeWidth: 3 },
134
+ { data: [0, 5, 12, 20, 28, 25, 18, 10, 4], color: '#0057b7', strokeWidth: 3 },
135
+ ]"
136
+ :height="200"
137
+ x-label="Weeks"
138
+ y-label="Incidence"
139
+ x-grid
140
+ y-grid
141
+ />
142
+
143
+ <template #code>
144
+
145
+ ```vue
146
+ <LineChart
147
+ :series="[
148
+ {
149
+ data: [0, 10, 25, 45, 60, 55, 40, 20, 8],
150
+ color: '#fb7e38',
151
+ strokeWidth: 3,
152
+ },
153
+ {
154
+ data: [0, 5, 12, 20, 28, 25, 18, 10, 4],
155
+ color: '#0057b7',
156
+ strokeWidth: 3,
157
+ },
158
+ ]"
159
+ :height="200"
160
+ x-label="Weeks"
161
+ y-label="Incidence"
162
+ x-grid
163
+ y-grid
164
+ />
165
+ ```
166
+
167
+ </template>
168
+ </ComponentDemo>
169
+
128
170
  <!--@include: ./_api/line-chart.md-->
129
171
 
130
172
  ### Series
@@ -1,7 +1,7 @@
1
1
  import { test, expect } from "@playwright/test";
2
2
 
3
3
  test("LineChart page renders demos", async ({ page }) => {
4
- await page.goto("/cfa-simulator/cfasim-ui/charts/line-chart");
4
+ await page.goto("./cfasim-ui/charts/line-chart");
5
5
  await expect(page.locator("h1")).toBeVisible();
6
6
  const demos = page.locator(".demo-preview");
7
7
  await expect(demos.first()).toBeVisible();
@@ -32,8 +32,11 @@ const props = withDefaults(
32
32
  yLabel?: string;
33
33
  yMin?: number;
34
34
  xMin?: number;
35
+ xLabels?: string[];
35
36
  debounce?: number;
36
37
  menu?: boolean | string;
38
+ xGrid?: boolean;
39
+ yGrid?: boolean;
37
40
  }>(),
38
41
  { lineOpacity: 1, menu: true },
39
42
  );
@@ -110,16 +113,19 @@ const extent = computed(() => {
110
113
  let max = -Infinity;
111
114
  for (const s of allSeries.value) {
112
115
  for (const v of s.data) {
116
+ if (!isFinite(v)) continue;
113
117
  if (v < min) min = v;
114
118
  if (v > max) max = v;
115
119
  }
116
120
  }
117
121
  for (const a of allAreas.value) {
118
122
  for (const v of a.upper) {
123
+ if (!isFinite(v)) continue;
119
124
  if (v < min) min = v;
120
125
  if (v > max) max = v;
121
126
  }
122
127
  for (const v of a.lower) {
128
+ if (!isFinite(v)) continue;
123
129
  if (v < min) min = v;
124
130
  if (v > max) max = v;
125
131
  }
@@ -136,9 +142,17 @@ function toPath(data: number[]): string {
136
142
  const xScale = innerW.value / (len - 1 || 1);
137
143
  const yScale = innerH.value / range;
138
144
  const py = padding.value.top + innerH.value;
139
- let d = `M${padding.value.left},${py - (data[0] - min) * yScale}`;
140
- for (let i = 1; i < data.length; i++) {
141
- d += `L${padding.value.left + i * xScale},${py - (data[i] - min) * yScale}`;
145
+ let d = "";
146
+ let inSegment = false;
147
+ for (let i = 0; i < data.length; i++) {
148
+ if (!isFinite(data[i])) {
149
+ inSegment = false;
150
+ continue;
151
+ }
152
+ const x = padding.value.left + i * xScale;
153
+ const y = py - (data[i] - min) * yScale;
154
+ d += inSegment ? `L${x},${y}` : `M${x},${y}`;
155
+ inSegment = true;
142
156
  }
143
157
  return d;
144
158
  }
@@ -153,10 +167,27 @@ function toAreaPath(upper: number[], lower: number[]): string {
153
167
  const py = padding.value.top + innerH.value;
154
168
  const x = (i: number) => padding.value.left + i * xScale;
155
169
  const y = (v: number) => py - (v - min) * yScale;
156
- let d = `M${x(0)},${y(upper[0])}`;
157
- for (let i = 1; i < len; i++) d += `L${x(i)},${y(upper[i])}`;
158
- for (let i = len - 1; i >= 0; i--) d += `L${x(i)},${y(lower[i])}`;
159
- return d + "Z";
170
+ // Collect contiguous segments where both upper and lower are finite
171
+ const segments: number[][] = [];
172
+ let seg: number[] = [];
173
+ for (let i = 0; i < len; i++) {
174
+ if (isFinite(upper[i]) && isFinite(lower[i])) {
175
+ seg.push(i);
176
+ } else if (seg.length) {
177
+ segments.push(seg);
178
+ seg = [];
179
+ }
180
+ }
181
+ if (seg.length) segments.push(seg);
182
+ let d = "";
183
+ for (const s of segments) {
184
+ d += `M${x(s[0])},${y(upper[s[0]])}`;
185
+ for (let j = 1; j < s.length; j++) d += `L${x(s[j])},${y(upper[s[j]])}`;
186
+ for (let j = s.length - 1; j >= 0; j--)
187
+ d += `L${x(s[j])},${y(lower[s[j]])}`;
188
+ d += "Z";
189
+ }
190
+ return d;
160
191
  }
161
192
 
162
193
  function niceStep(range: number, targetTicks: number): number {
@@ -171,6 +202,11 @@ function niceStep(range: number, targetTicks: number): number {
171
202
  return step * mag;
172
203
  }
173
204
 
205
+ /** Round to nearest half-pixel so 1px SVG strokes stay sharp. */
206
+ function snap(v: number): number {
207
+ return Math.round(v) + 0.5;
208
+ }
209
+
174
210
  const numFmt = new Intl.NumberFormat();
175
211
  function formatTick(v: number): string {
176
212
  if (Math.abs(v) >= 1000) return numFmt.format(v);
@@ -184,7 +220,7 @@ const yTicks = computed(() => {
184
220
  return [
185
221
  {
186
222
  value: formatTick(min),
187
- y: padding.value.top + innerH.value / 2,
223
+ y: snap(padding.value.top + innerH.value / 2),
188
224
  },
189
225
  ];
190
226
  }
@@ -195,10 +231,11 @@ const yTicks = computed(() => {
195
231
  for (let v = start; v <= max; v += step) {
196
232
  ticks.push({
197
233
  value: formatTick(v),
198
- y:
234
+ y: snap(
199
235
  padding.value.top +
200
- innerH.value -
201
- ((v - min) / extent.value.range) * innerH.value,
236
+ innerH.value -
237
+ ((v - min) / extent.value.range) * innerH.value,
238
+ ),
202
239
  });
203
240
  }
204
241
  return ticks;
@@ -207,6 +244,19 @@ const yTicks = computed(() => {
207
244
  const xTicks = computed(() => {
208
245
  const len = maxLen.value;
209
246
  if (len <= 1) return [];
247
+ const labels = props.xLabels;
248
+ if (labels && labels.length === len) {
249
+ const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
250
+ const step = Math.max(1, Math.round((len - 1) / targetTicks));
251
+ const ticks: { value: string; x: number }[] = [];
252
+ for (let i = 0; i < len; i += step) {
253
+ ticks.push({
254
+ value: labels[i],
255
+ x: snap(padding.value.left + (i / (len - 1)) * innerW.value),
256
+ });
257
+ }
258
+ return ticks;
259
+ }
210
260
  const offset = props.xMin ?? 0;
211
261
  const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
212
262
  const step = niceStep(len - 1, targetTicks);
@@ -215,7 +265,7 @@ const xTicks = computed(() => {
215
265
  const idx = Math.round(i);
216
266
  ticks.push({
217
267
  value: formatTick(idx + offset),
218
- x: padding.value.left + (idx / (len - 1)) * innerW.value,
268
+ x: snap(padding.value.left + (idx / (len - 1)) * innerW.value),
219
269
  });
220
270
  }
221
271
  return ticks;
@@ -288,21 +338,47 @@ const menuItems = computed<ChartMenuItem[]>(() => {
288
338
  </text>
289
339
  <!-- axes -->
290
340
  <line
291
- :x1="padding.left"
292
- :y1="padding.top"
293
- :x2="padding.left"
294
- :y2="padding.top + innerH"
341
+ :x1="snap(padding.left)"
342
+ :y1="snap(padding.top)"
343
+ :x2="snap(padding.left)"
344
+ :y2="snap(padding.top + innerH)"
295
345
  stroke="currentColor"
296
346
  stroke-opacity="0.3"
297
347
  />
298
348
  <line
299
- :x1="padding.left"
300
- :y1="padding.top + innerH"
301
- :x2="padding.left + innerW"
302
- :y2="padding.top + innerH"
349
+ :x1="snap(padding.left)"
350
+ :y1="snap(padding.top + innerH)"
351
+ :x2="snap(padding.left + innerW)"
352
+ :y2="snap(padding.top + innerH)"
303
353
  stroke="currentColor"
304
354
  stroke-opacity="0.3"
305
355
  />
356
+ <!-- y grid lines -->
357
+ <template v-if="yGrid">
358
+ <line
359
+ v-for="(tick, i) in yTicks"
360
+ :key="'yg' + i"
361
+ :x1="padding.left"
362
+ :y1="tick.y"
363
+ :x2="padding.left + innerW"
364
+ :y2="tick.y"
365
+ stroke="currentColor"
366
+ stroke-opacity="0.1"
367
+ />
368
+ </template>
369
+ <!-- x grid lines -->
370
+ <template v-if="xGrid">
371
+ <line
372
+ v-for="(tick, i) in xTicks"
373
+ :key="'xg' + i"
374
+ :x1="tick.x"
375
+ :y1="padding.top"
376
+ :x2="tick.x"
377
+ :y2="padding.top + innerH"
378
+ stroke="currentColor"
379
+ stroke-opacity="0.1"
380
+ />
381
+ </template>
306
382
  <!-- y tick labels -->
307
383
  <text
308
384
  v-for="(tick, i) in yTicks"