@opendata-ai/openchart-core 6.2.0 → 6.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-core",
3
- "version": "6.2.0",
3
+ "version": "6.3.0",
4
4
  "description": "Types, theme, colors, accessibility, and utilities for openchart",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
package/src/index.ts CHANGED
@@ -88,8 +88,15 @@ export {
88
88
  export type {
89
89
  LabelCandidate,
90
90
  LabelPriority,
91
+ OffsetStrategy,
92
+ } from './labels/index';
93
+ export {
94
+ computeLabelBounds,
95
+ detectCollision,
96
+ EXTENDED_OFFSET_STRATEGIES,
97
+ OFFSET_STRATEGIES,
98
+ resolveCollisions,
91
99
  } from './labels/index';
92
- export { computeLabelBounds, detectCollision, resolveCollisions } from './labels/index';
93
100
 
94
101
  // ---------------------------------------------------------------------------
95
102
  // Locale: number and date formatting
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { TextStyle } from '../../types/layout';
3
- import type { LabelCandidate } from '../collision';
4
- import { detectCollision, resolveCollisions } from '../collision';
3
+ import {
4
+ detectCollision,
5
+ EXTENDED_OFFSET_STRATEGIES,
6
+ type LabelCandidate,
7
+ OFFSET_STRATEGIES,
8
+ resolveCollisions,
9
+ } from '../collision';
5
10
 
6
11
  const defaultStyle: TextStyle = {
7
12
  fontFamily: 'Inter',
@@ -194,4 +199,64 @@ describe('resolveCollisions', () => {
194
199
  it('handles empty input', () => {
195
200
  expect(resolveCollisions([])).toEqual([]);
196
201
  });
202
+
203
+ it('accepts custom strategies without breaking default behavior', () => {
204
+ const labels: LabelCandidate[] = [
205
+ {
206
+ text: 'A',
207
+ anchorX: 0,
208
+ anchorY: 0,
209
+ width: 20,
210
+ height: 14,
211
+ priority: 'data',
212
+ style: defaultStyle,
213
+ },
214
+ {
215
+ text: 'B',
216
+ anchorX: 100,
217
+ anchorY: 100,
218
+ width: 20,
219
+ height: 14,
220
+ priority: 'data',
221
+ style: defaultStyle,
222
+ },
223
+ ];
224
+
225
+ // Explicit default strategies should behave the same as no parameter
226
+ const defaultResults = resolveCollisions(labels);
227
+ const explicitResults = resolveCollisions(labels, OFFSET_STRATEGIES);
228
+ expect(explicitResults).toEqual(defaultResults);
229
+ });
230
+
231
+ it('resolves more dense labels with extended strategies than default', () => {
232
+ // 10 labels at the exact same position, simulating many converging line endpoints.
233
+ // The default 7 strategies can place at most 7; extended strategies should place more.
234
+ const labels: LabelCandidate[] = Array.from({ length: 10 }, (_, i) => ({
235
+ text: `Series ${i}`,
236
+ anchorX: 400,
237
+ anchorY: 200,
238
+ width: 60,
239
+ height: 14,
240
+ priority: 'data' as const,
241
+ style: defaultStyle,
242
+ }));
243
+
244
+ const defaultResults = resolveCollisions(labels);
245
+ const extendedResults = resolveCollisions(labels, EXTENDED_OFFSET_STRATEGIES);
246
+
247
+ const defaultVisible = defaultResults.filter((r) => r.visible).length;
248
+ const extendedVisible = extendedResults.filter((r) => r.visible).length;
249
+
250
+ // Default has 7 strategies, so at most 7 can be placed
251
+ expect(defaultVisible).toBeLessThanOrEqual(OFFSET_STRATEGIES.length);
252
+ // Extended strategies should place more labels than default
253
+ expect(extendedVisible).toBeGreaterThan(defaultVisible);
254
+ });
255
+
256
+ it('EXTENDED_OFFSET_STRATEGIES includes all base strategies', () => {
257
+ for (const strategy of OFFSET_STRATEGIES) {
258
+ expect(EXTENDED_OFFSET_STRATEGIES).toContainEqual(strategy);
259
+ }
260
+ expect(EXTENDED_OFFSET_STRATEGIES.length).toBeGreaterThan(OFFSET_STRATEGIES.length);
261
+ });
197
262
  });
@@ -59,8 +59,14 @@ export function detectCollision(a: Rect, b: Rect): boolean {
59
59
  // Offset strategies
60
60
  // ---------------------------------------------------------------------------
61
61
 
62
+ /** An offset position to try when resolving a label collision. */
63
+ export interface OffsetStrategy {
64
+ dx: number;
65
+ dy: number;
66
+ }
67
+
62
68
  /** Offsets to try when a label collides with an existing placement. */
63
- const OFFSET_STRATEGIES = [
69
+ export const OFFSET_STRATEGIES: readonly OffsetStrategy[] = [
64
70
  { dx: 0, dy: 0 }, // original position
65
71
  { dx: 0, dy: -1.2 }, // above (factor of height)
66
72
  { dx: 0, dy: 1.2 }, // below
@@ -70,6 +76,20 @@ const OFFSET_STRATEGIES = [
70
76
  { dx: -1.1, dy: -1.2 }, // upper-left
71
77
  ];
72
78
 
79
+ /**
80
+ * Extended offset strategies with additional vertical spread for dense
81
+ * multi-series endpoints (e.g. 5+ line series converging at similar y-values).
82
+ */
83
+ export const EXTENDED_OFFSET_STRATEGIES: readonly OffsetStrategy[] = [
84
+ ...OFFSET_STRATEGIES,
85
+ { dx: 0, dy: -2.4 }, // further above
86
+ { dx: 0, dy: 2.4 }, // further below
87
+ { dx: 0, dy: -3.6 }, // even further above
88
+ { dx: 0, dy: 3.6 }, // even further below
89
+ { dx: 1.1, dy: -2.4 }, // upper-right far
90
+ { dx: 1.1, dy: 2.4 }, // lower-right far
91
+ ];
92
+
73
93
  // ---------------------------------------------------------------------------
74
94
  // Public API
75
95
  // ---------------------------------------------------------------------------
@@ -83,9 +103,13 @@ const OFFSET_STRATEGIES = [
83
103
  * demoted to tooltip-only (visible: false).
84
104
  *
85
105
  * @param labels - Array of label candidates to position.
106
+ * @param strategies - Optional offset strategies to use (defaults to OFFSET_STRATEGIES).
86
107
  * @returns Array of resolved labels with computed positions and visibility.
87
108
  */
88
- export function resolveCollisions(labels: LabelCandidate[]): ResolvedLabel[] {
109
+ export function resolveCollisions(
110
+ labels: LabelCandidate[],
111
+ strategies: readonly OffsetStrategy[] = OFFSET_STRATEGIES,
112
+ ): ResolvedLabel[] {
89
113
  // Sort by priority (highest first)
90
114
  const sorted = [...labels].sort(
91
115
  (a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority],
@@ -100,7 +124,7 @@ export function resolveCollisions(labels: LabelCandidate[]): ResolvedLabel[] {
100
124
  let bestY = label.anchorY;
101
125
 
102
126
  // Try each offset strategy
103
- for (const offset of OFFSET_STRATEGIES) {
127
+ for (const offset of strategies) {
104
128
  const candidateX = label.anchorX + offset.dx * label.width;
105
129
  const candidateY = label.anchorY + offset.dy * label.height;
106
130
  const candidateRect: Rect = {
@@ -2,5 +2,11 @@
2
2
  * Labels module barrel export.
3
3
  */
4
4
 
5
- export type { LabelCandidate, LabelPriority } from './collision';
6
- export { computeLabelBounds, detectCollision, resolveCollisions } from './collision';
5
+ export type { LabelCandidate, LabelPriority, OffsetStrategy } from './collision';
6
+ export {
7
+ computeLabelBounds,
8
+ detectCollision,
9
+ EXTENDED_OFFSET_STRATEGIES,
10
+ OFFSET_STRATEGIES,
11
+ resolveCollisions,
12
+ } from './collision';
@@ -102,6 +102,14 @@ describe('buildD3Formatter', () => {
102
102
  it('returns null for completely invalid format', () => {
103
103
  expect(buildD3Formatter('not-a-format!!!')).toBeNull();
104
104
  });
105
+
106
+ it('$~s formats low thousands with SI suffix', () => {
107
+ const fmt = buildD3Formatter('$~s');
108
+ expect(fmt).not.toBeNull();
109
+ expect(fmt!(6000)).toBe('$6k');
110
+ expect(fmt!(7000)).toBe('$7k');
111
+ expect(fmt!(14000)).toBe('$14k');
112
+ });
105
113
  });
106
114
 
107
115
  describe('formatDate', () => {
@@ -136,26 +136,32 @@ const GRANULARITY_FORMATS: Record<DateGranularity, string> = {
136
136
  * @param value - Date object, ISO string, or timestamp number.
137
137
  * @param locale - Locale string (currently unused, reserved for i18n).
138
138
  * @param granularity - Time granularity for format selection.
139
+ * @param useUtc - Whether to infer granularity and format using UTC methods.
140
+ * Pass `false` when formatting ticks from a local-time scale (d3 scaleTime),
141
+ * so that e.g. midnight local isn't misread as an intra-day UTC time.
142
+ * Defaults to `true` for backward compatibility.
139
143
  */
140
144
  export function formatDate(
141
145
  value: Date | string | number,
142
146
  _locale?: string,
143
147
  granularity?: DateGranularity,
148
+ useUtc: boolean = true,
144
149
  ): string {
145
150
  const date = value instanceof Date ? value : new Date(value);
146
151
  if (Number.isNaN(date.getTime())) return String(value);
147
152
 
148
- const gran = granularity ?? inferGranularity(date);
153
+ const gran = granularity ?? inferGranularity(date, useUtc);
149
154
 
150
155
  // Special handling for quarter (not a d3 format token)
151
156
  if (gran === 'quarter') {
152
- const q = Math.ceil((date.getMonth() + 1) / 3);
157
+ const q = useUtc
158
+ ? Math.ceil((date.getUTCMonth() + 1) / 3)
159
+ : Math.ceil((date.getMonth() + 1) / 3);
153
160
  return `Q${q} ${date.getFullYear()}`;
154
161
  }
155
162
 
156
163
  const formatStr = GRANULARITY_FORMATS[gran];
157
- // Use UTC format for year/month/day to avoid timezone shifts
158
- if (['year', 'month', 'day'].includes(gran)) {
164
+ if (useUtc) {
159
165
  return utcFormat(formatStr)(date);
160
166
  }
161
167
  return timeFormat(formatStr)(date);
@@ -181,12 +187,20 @@ export function buildTemporalFormatter(
181
187
  /**
182
188
  * Infer the appropriate granularity from a date value.
183
189
  * If time components are all zero, assume day or higher.
190
+ *
191
+ * @param useUtc - When true, inspect UTC fields. When false, inspect local-time
192
+ * fields. This must match the D3 scale type: scaleUtc -> true, scaleTime -> false.
184
193
  */
185
- function inferGranularity(date: Date): DateGranularity {
186
- if (date.getUTCHours() !== 0 || date.getUTCMinutes() !== 0) {
187
- return date.getUTCMinutes() !== 0 ? 'minute' : 'hour';
194
+ function inferGranularity(date: Date, useUtc: boolean = true): DateGranularity {
195
+ const hours = useUtc ? date.getUTCHours() : date.getHours();
196
+ const minutes = useUtc ? date.getUTCMinutes() : date.getMinutes();
197
+ const day = useUtc ? date.getUTCDate() : date.getDate();
198
+ const month = useUtc ? date.getUTCMonth() : date.getMonth();
199
+
200
+ if (hours !== 0 || minutes !== 0) {
201
+ return minutes !== 0 ? 'minute' : 'hour';
188
202
  }
189
- if (date.getUTCDate() !== 1) return 'day';
190
- if (date.getUTCMonth() !== 0) return 'month';
203
+ if (day !== 1) return 'day';
204
+ if (month !== 0) return 'month';
191
205
  return 'year';
192
206
  }
@@ -27,7 +27,7 @@ function getOptionalChannels(markType: MarkType): string[] {
27
27
  // ---------------------------------------------------------------------------
28
28
 
29
29
  describe('MARK_ENCODING_RULES', () => {
30
- it('has entries for all 10 mark types', () => {
30
+ it('has entries for all 11 mark types', () => {
31
31
  const expectedTypes: MarkType[] = [
32
32
  'bar',
33
33
  'line',
@@ -39,6 +39,7 @@ describe('MARK_ENCODING_RULES', () => {
39
39
  'rule',
40
40
  'tick',
41
41
  'rect',
42
+ 'lollipop',
42
43
  ];
43
44
  for (const type of expectedTypes) {
44
45
  expect(MARK_ENCODING_RULES[type]).toBeDefined();
@@ -110,6 +111,17 @@ describe('line encoding rules', () => {
110
111
  });
111
112
 
112
113
  describe('point encoding rules', () => {
114
+ it('accepts all four field types on x and y', () => {
115
+ const rules = MARK_ENCODING_RULES.point;
116
+ for (const axis of ['x', 'y'] as const) {
117
+ expect(rules[axis].required).toBe(true);
118
+ expect(rules[axis].allowedTypes).toContain('quantitative');
119
+ expect(rules[axis].allowedTypes).toContain('temporal');
120
+ expect(rules[axis].allowedTypes).toContain('nominal');
121
+ expect(rules[axis].allowedTypes).toContain('ordinal');
122
+ }
123
+ });
124
+
113
125
  it('supports shape as optional', () => {
114
126
  expect(getOptionalChannels('point')).toContain('shape');
115
127
  });
@@ -200,6 +212,23 @@ describe('rect encoding rules', () => {
200
212
  });
201
213
  });
202
214
 
215
+ describe('lollipop encoding rules', () => {
216
+ it('has the same rules as circle (semantic alias)', () => {
217
+ const lollipopRules = MARK_ENCODING_RULES.lollipop;
218
+ const circleRules = MARK_ENCODING_RULES.circle;
219
+ expect(lollipopRules).toEqual(circleRules);
220
+ });
221
+
222
+ it('requires x (quantitative) and y (nominal/ordinal)', () => {
223
+ const rules = MARK_ENCODING_RULES.lollipop;
224
+ expect(rules.x.required).toBe(true);
225
+ expect(rules.x.allowedTypes).toEqual(['quantitative']);
226
+ expect(rules.y.required).toBe(true);
227
+ expect(rules.y.allowedTypes).toContain('nominal');
228
+ expect(rules.y.allowedTypes).toContain('ordinal');
229
+ });
230
+ });
231
+
203
232
  describe('common channels across marks', () => {
204
233
  it('tooltip is optional on all mark types', () => {
205
234
  const allTypes: MarkType[] = [
@@ -213,6 +242,7 @@ describe('common channels across marks', () => {
213
242
  'rule',
214
243
  'tick',
215
244
  'rect',
245
+ 'lollipop',
216
246
  ];
217
247
  for (const type of allTypes) {
218
248
  const rules = MARK_ENCODING_RULES[type];
@@ -234,6 +264,7 @@ describe('common channels across marks', () => {
234
264
  'rule',
235
265
  'tick',
236
266
  'rect',
267
+ 'lollipop',
237
268
  ];
238
269
  for (const type of allTypes) {
239
270
  const rules = MARK_ENCODING_RULES[type];
@@ -255,6 +286,7 @@ describe('common channels across marks', () => {
255
286
  'rule',
256
287
  'tick',
257
288
  'rect',
289
+ 'lollipop',
258
290
  ];
259
291
  for (const type of allTypes) {
260
292
  const rules = MARK_ENCODING_RULES[type];
@@ -81,6 +81,7 @@ describe('isChartSpec', () => {
81
81
  'rule',
82
82
  'tick',
83
83
  'rect',
84
+ 'lollipop',
84
85
  ] as const;
85
86
 
86
87
  for (const markType of markTypes) {
@@ -151,8 +152,8 @@ describe('type guard mutual exclusivity', () => {
151
152
  // ---------------------------------------------------------------------------
152
153
 
153
154
  describe('MARK_TYPES', () => {
154
- it('contains all 10 mark types', () => {
155
- expect(MARK_TYPES.size).toBe(10);
155
+ it('contains all 11 mark types', () => {
156
+ expect(MARK_TYPES.size).toBe(11);
156
157
  });
157
158
 
158
159
  it('contains expected types', () => {
@@ -167,6 +168,7 @@ describe('MARK_TYPES', () => {
167
168
  'rule',
168
169
  'tick',
169
170
  'rect',
171
+ 'lollipop',
170
172
  ];
171
173
  for (const t of expected) {
172
174
  expect(MARK_TYPES.has(t)).toBe(true);
@@ -110,8 +110,8 @@ export const MARK_ENCODING_RULES: Record<MarkType, EncodingRule> = {
110
110
  detail: optional('nominal'),
111
111
  },
112
112
  point: {
113
- x: required('quantitative'),
114
- y: required('quantitative'),
113
+ x: required('quantitative', 'temporal', 'nominal', 'ordinal'),
114
+ y: required('quantitative', 'temporal', 'nominal', 'ordinal'),
115
115
  color: optional('nominal', 'ordinal', 'quantitative'),
116
116
  size: optional('quantitative'),
117
117
  shape: optional('nominal', 'ordinal'),
@@ -132,6 +132,17 @@ export const MARK_ENCODING_RULES: Record<MarkType, EncodingRule> = {
132
132
  order: optional('quantitative', 'ordinal'),
133
133
  detail: optional('nominal'),
134
134
  },
135
+ lollipop: {
136
+ x: required('quantitative'),
137
+ y: required('nominal', 'ordinal'),
138
+ color: optional('nominal', 'ordinal', 'quantitative'),
139
+ size: optional('quantitative'),
140
+ opacity: optional('quantitative'),
141
+ tooltip: optional(),
142
+ href: optional(),
143
+ order: optional('quantitative', 'ordinal'),
144
+ detail: optional('nominal'),
145
+ },
135
146
  arc: {
136
147
  x: optional(),
137
148
  y: required('quantitative'),
package/src/types/spec.ts CHANGED
@@ -42,7 +42,8 @@ export type MarkType =
42
42
  | 'text'
43
43
  | 'rule'
44
44
  | 'tick'
45
- | 'rect';
45
+ | 'rect'
46
+ | 'lollipop';
46
47
 
47
48
  /** @deprecated Use MarkType instead. Kept for internal migration references. */
48
49
  export type ChartType = MarkType;
@@ -567,6 +568,10 @@ export interface LegendConfig {
567
568
  offset?: AnnotationOffset;
568
569
  /** Whether to show the legend. Defaults to true. Set to false to hide. */
569
570
  show?: boolean;
571
+ /** Number of columns for horizontal legend layout. Overrides the default row limit. */
572
+ columns?: number;
573
+ /** Max number of legend entries before truncation. Remaining entries show as "+N more". */
574
+ symbolLimit?: number;
570
575
  }
571
576
 
572
577
  // ---------------------------------------------------------------------------
@@ -1007,6 +1012,7 @@ export const MARK_TYPES: ReadonlySet<string> = new Set<MarkType>([
1007
1012
  'rule',
1008
1013
  'tick',
1009
1014
  'rect',
1015
+ 'lollipop',
1010
1016
  ]);
1011
1017
 
1012
1018
  /** @deprecated Use MARK_TYPES instead. */
@@ -1085,4 +1091,5 @@ export const MARK_DISPLAY_NAMES: Record<MarkType, string> = {
1085
1091
  rule: 'Rule chart',
1086
1092
  tick: 'Tick plot',
1087
1093
  rect: 'Heatmap',
1094
+ lollipop: 'Lollipop chart',
1088
1095
  };