@opendata-ai/openchart-core 6.1.4 → 6.2.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.1.4",
3
+ "version": "6.2.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
@@ -99,6 +99,7 @@ export type { DateGranularity } from './locale/index';
99
99
  export {
100
100
  abbreviateNumber,
101
101
  buildD3Formatter,
102
+ buildTemporalFormatter,
102
103
  formatDate,
103
104
  formatNumber,
104
105
  } from './locale/index';
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { abbreviateNumber, buildD3Formatter, formatDate, formatNumber } from '../format';
2
+ import {
3
+ abbreviateNumber,
4
+ buildD3Formatter,
5
+ buildTemporalFormatter,
6
+ formatDate,
7
+ formatNumber,
8
+ } from '../format';
3
9
 
4
10
  describe('formatNumber', () => {
5
11
  it('formats integers with commas', () => {
@@ -126,4 +132,76 @@ describe('formatDate', () => {
126
132
  const result = formatDate('not-a-date');
127
133
  expect(result).toBe('not-a-date');
128
134
  });
135
+
136
+ it('infers year granularity for bare year strings regardless of timezone', () => {
137
+ // "2020" parses as 2020-01-01T00:00:00Z. In negative-offset timezones,
138
+ // local getHours()/getDate() would see Dec 31 2019 — the UTC fix prevents that.
139
+ const result = formatDate('2020');
140
+ expect(result).toBe('2020');
141
+ });
142
+
143
+ it('infers year granularity for Jan 1 ISO dates', () => {
144
+ const result = formatDate('2020-01-01');
145
+ expect(result).toBe('2020');
146
+ });
147
+
148
+ it('infers month granularity for first-of-month dates', () => {
149
+ const result = formatDate('2020-06-01');
150
+ expect(result).toContain('Jun');
151
+ expect(result).toContain('2020');
152
+ });
153
+
154
+ it('infers day granularity for mid-month dates', () => {
155
+ const result = formatDate('2020-06-15');
156
+ expect(result).toContain('15');
157
+ expect(result).toContain('Jun');
158
+ });
159
+ });
160
+
161
+ describe('buildTemporalFormatter', () => {
162
+ it('returns null for undefined format', () => {
163
+ expect(buildTemporalFormatter(undefined)).toBeNull();
164
+ });
165
+
166
+ it('returns null for empty string', () => {
167
+ expect(buildTemporalFormatter('')).toBeNull();
168
+ });
169
+
170
+ it('formats dates with %Y to just the year', () => {
171
+ const fmt = buildTemporalFormatter('%Y');
172
+ expect(fmt).not.toBeNull();
173
+ expect(fmt!('2020-01-01')).toBe('2020');
174
+ expect(fmt!(new Date('2020-06-15'))).toBe('2020');
175
+ });
176
+
177
+ it('formats dates with %b %Y to month and year', () => {
178
+ const fmt = buildTemporalFormatter('%b %Y');
179
+ expect(fmt).not.toBeNull();
180
+ expect(fmt!('2020-06-01')).toBe('Jun 2020');
181
+ });
182
+
183
+ it('formats dates with full date format', () => {
184
+ const fmt = buildTemporalFormatter('%Y-%m-%d');
185
+ expect(fmt).not.toBeNull();
186
+ expect(fmt!('2020-06-15')).toBe('2020-06-15');
187
+ });
188
+
189
+ it('handles invalid date input gracefully', () => {
190
+ const fmt = buildTemporalFormatter('%Y');
191
+ expect(fmt).not.toBeNull();
192
+ expect(fmt!('not-a-date')).toBe('not-a-date');
193
+ });
194
+
195
+ it('handles Date objects', () => {
196
+ const fmt = buildTemporalFormatter('%Y');
197
+ expect(fmt).not.toBeNull();
198
+ expect(fmt!(new Date('2025-01-01T00:00:00Z'))).toBe('2025');
199
+ });
200
+
201
+ it('handles numeric timestamps', () => {
202
+ const fmt = buildTemporalFormatter('%Y');
203
+ expect(fmt).not.toBeNull();
204
+ // 2020-01-01T00:00:00Z
205
+ expect(fmt!(1577836800000)).toBe('2020');
206
+ });
129
207
  });
@@ -161,15 +161,32 @@ export function formatDate(
161
161
  return timeFormat(formatStr)(date);
162
162
  }
163
163
 
164
+ /**
165
+ * Build a formatter for temporal values using a d3-time-format string (e.g. "%Y", "%b %Y").
166
+ * Returns a function that accepts a Date, string, or number and returns the formatted string.
167
+ * Returns null if the format string is falsy.
168
+ */
169
+ export function buildTemporalFormatter(
170
+ formatStr: string | undefined,
171
+ ): ((value: Date | string | number) => string) | null {
172
+ if (!formatStr) return null;
173
+ const fmt = utcFormat(formatStr);
174
+ return (value: Date | string | number) => {
175
+ const date = value instanceof Date ? value : new Date(value);
176
+ if (Number.isNaN(date.getTime())) return String(value);
177
+ return fmt(date);
178
+ };
179
+ }
180
+
164
181
  /**
165
182
  * Infer the appropriate granularity from a date value.
166
183
  * If time components are all zero, assume day or higher.
167
184
  */
168
185
  function inferGranularity(date: Date): DateGranularity {
169
- if (date.getHours() !== 0 || date.getMinutes() !== 0) {
170
- return date.getMinutes() !== 0 ? 'minute' : 'hour';
186
+ if (date.getUTCHours() !== 0 || date.getUTCMinutes() !== 0) {
187
+ return date.getUTCMinutes() !== 0 ? 'minute' : 'hour';
171
188
  }
172
- if (date.getDate() !== 1) return 'day';
173
- if (date.getMonth() !== 0) return 'month';
189
+ if (date.getUTCDate() !== 1) return 'day';
190
+ if (date.getUTCMonth() !== 0) return 'month';
174
191
  return 'year';
175
192
  }
@@ -3,4 +3,10 @@
3
3
  */
4
4
 
5
5
  export type { DateGranularity } from './format';
6
- export { abbreviateNumber, buildD3Formatter, formatDate, formatNumber } from './format';
6
+ export {
7
+ abbreviateNumber,
8
+ buildD3Formatter,
9
+ buildTemporalFormatter,
10
+ formatDate,
11
+ formatNumber,
12
+ } from './format';
package/src/types/spec.ts CHANGED
@@ -273,12 +273,14 @@ export interface Encoding {
273
273
  // Graph-specific encoding
274
274
  // ---------------------------------------------------------------------------
275
275
 
276
- /** Encoding channel for graph nodes and edges. Same structure as EncodingChannel. */
276
+ /** Encoding channel for graph nodes and edges. */
277
277
  export interface GraphEncodingChannel {
278
278
  /** Data field name on the node/edge object. */
279
279
  field: string;
280
280
  /** How to interpret the field values. */
281
281
  type?: FieldType;
282
+ /** Scale configuration. Auto-derived from data if omitted. */
283
+ scale?: ScaleConfig;
282
284
  }
283
285
 
284
286
  /** Graph-specific encoding mapping visual properties to node/edge data fields. */