@opendata-ai/openchart-core 7.9.2 → 7.10.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/dist/index.d.ts +18 -2
- package/dist/index.js +23 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/locale/__tests__/format.test.ts +48 -0
- package/src/locale/format.ts +25 -2
- package/src/responsive/index.ts +2 -0
- package/src/responsive/metrics.ts +18 -2
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -112,9 +112,11 @@ export {
|
|
|
112
112
|
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
|
|
113
113
|
NARROW_VIEWPORT_MAX,
|
|
114
114
|
TICK_LABEL_OFFSET,
|
|
115
|
+
TICK_LINE_HEIGHT_FACTOR,
|
|
115
116
|
TOP_PAD_EXTRA_NARROW,
|
|
116
117
|
TOP_PAD_NARROW_MAX,
|
|
117
118
|
X_AXIS_BAND_HEIGHT,
|
|
119
|
+
X_AXIS_ROTATED_EXTENT_CAP,
|
|
118
120
|
X_AXIS_TITLE_BAND,
|
|
119
121
|
X_AXIS_TITLE_BAND_ROTATED,
|
|
120
122
|
} from './responsive/index';
|
|
@@ -171,6 +171,54 @@ describe('formatDate', () => {
|
|
|
171
171
|
expect(result).toContain('15');
|
|
172
172
|
expect(result).toContain('Jun');
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
describe('compact formats', () => {
|
|
176
|
+
it('formats months as bare month name', () => {
|
|
177
|
+
expect(formatDate('2025-02-01', undefined, undefined, true, true)).toBe('Feb');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('infers year granularity at year boundaries (compact stays %Y)', () => {
|
|
181
|
+
expect(formatDate('2025-01-01', undefined, undefined, true, true)).toBe('2025');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('formats days without the year', () => {
|
|
185
|
+
expect(formatDate('2025-03-05', undefined, undefined, true, true)).toBe('Mar 05');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('formats quarters with apostrophe year', () => {
|
|
189
|
+
expect(formatDate('2025-04-15', undefined, 'quarter', true, true)).toBe("Q2 '25");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('formats quarters with local-time year when useUtc is false', () => {
|
|
193
|
+
// Mid-quarter local date: quarter and year agree between local and UTC,
|
|
194
|
+
// exercising the getFullYear() arm of the compact branch.
|
|
195
|
+
expect(formatDate(new Date(2025, 4, 15), undefined, 'quarter', false, true)).toBe("Q2 '25");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('formats weeks as month and day', () => {
|
|
199
|
+
expect(formatDate('2025-03-10', undefined, 'week', true, true)).toBe('Mar 10');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('formats hours as time only', () => {
|
|
203
|
+
expect(formatDate('2025-03-05T14:00:00Z', undefined, undefined, true, true)).toBe('14:00');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('formats minutes as time only', () => {
|
|
207
|
+
expect(formatDate('2025-03-05T14:30:00Z', undefined, undefined, true, true)).toBe('14:30');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('default (compact: false) output is unchanged', () => {
|
|
211
|
+
expect(formatDate('2025-02-01')).toBe('Feb 2025');
|
|
212
|
+
expect(formatDate('2025-03-05')).toBe('Mar 05, 2025');
|
|
213
|
+
expect(formatDate('2025-04-15', undefined, 'quarter')).toBe('Q2 2025');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('full quarter year follows useUtc at year boundaries', () => {
|
|
217
|
+
// In negative-offset timezones this instant is still Dec 31 2025 locally;
|
|
218
|
+
// the UTC quarter (Q1) must pair with the UTC year (2026), not the local one.
|
|
219
|
+
expect(formatDate(new Date('2026-01-01T00:00:00Z'), undefined, 'quarter')).toBe('Q1 2026');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
174
222
|
});
|
|
175
223
|
|
|
176
224
|
describe('buildTemporalFormatter', () => {
|
package/src/locale/format.ts
CHANGED
|
@@ -135,6 +135,22 @@ const GRANULARITY_FORMATS: Record<DateGranularity, string> = {
|
|
|
135
135
|
minute: '%H:%M',
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Compact format strings used by axis ticks when full-format labels overlap.
|
|
140
|
+
* Month is bare '%b': granularity inference runs per tick, so year-boundary
|
|
141
|
+
* ticks already render as '2025' and the axis reads "2025 · Feb · Mar".
|
|
142
|
+
* Year stays '%Y' — abbreviating to "'25" saves too little for the ambiguity.
|
|
143
|
+
*/
|
|
144
|
+
const GRANULARITY_FORMATS_COMPACT: Record<DateGranularity, string> = {
|
|
145
|
+
year: '%Y',
|
|
146
|
+
quarter: '', // special-cased below, like GRANULARITY_FORMATS
|
|
147
|
+
month: '%b',
|
|
148
|
+
week: '%b %d',
|
|
149
|
+
day: '%b %d',
|
|
150
|
+
hour: '%H:%M',
|
|
151
|
+
minute: '%H:%M',
|
|
152
|
+
};
|
|
153
|
+
|
|
138
154
|
/**
|
|
139
155
|
* Format a date value for display.
|
|
140
156
|
*
|
|
@@ -145,12 +161,15 @@ const GRANULARITY_FORMATS: Record<DateGranularity, string> = {
|
|
|
145
161
|
* Pass `false` when formatting ticks from a local-time scale (d3 scaleTime),
|
|
146
162
|
* so that e.g. midnight local isn't misread as an intra-day UTC time.
|
|
147
163
|
* Defaults to `true` for backward compatibility.
|
|
164
|
+
* @param compact - Use the compact format table (axis ticks whose full-format
|
|
165
|
+
* labels would overlap). Defaults to `false`.
|
|
148
166
|
*/
|
|
149
167
|
export function formatDate(
|
|
150
168
|
value: Date | string | number,
|
|
151
169
|
_locale?: string,
|
|
152
170
|
granularity?: DateGranularity,
|
|
153
171
|
useUtc: boolean = true,
|
|
172
|
+
compact: boolean = false,
|
|
154
173
|
): string {
|
|
155
174
|
const date = value instanceof Date ? value : new Date(value);
|
|
156
175
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
@@ -162,10 +181,14 @@ export function formatDate(
|
|
|
162
181
|
const q = useUtc
|
|
163
182
|
? Math.ceil((date.getUTCMonth() + 1) / 3)
|
|
164
183
|
: Math.ceil((date.getMonth() + 1) / 3);
|
|
165
|
-
|
|
184
|
+
const year = useUtc ? date.getUTCFullYear() : date.getFullYear();
|
|
185
|
+
if (compact) {
|
|
186
|
+
return `Q${q} '${String(year).slice(-2)}`;
|
|
187
|
+
}
|
|
188
|
+
return `Q${q} ${year}`;
|
|
166
189
|
}
|
|
167
190
|
|
|
168
|
-
const formatStr = GRANULARITY_FORMATS[gran];
|
|
191
|
+
const formatStr = (compact ? GRANULARITY_FORMATS_COMPACT : GRANULARITY_FORMATS)[gran];
|
|
169
192
|
if (useUtc) {
|
|
170
193
|
return utcFormat(formatStr)(date);
|
|
171
194
|
}
|
package/src/responsive/index.ts
CHANGED
|
@@ -41,9 +41,11 @@ export {
|
|
|
41
41
|
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
|
|
42
42
|
NARROW_VIEWPORT_MAX,
|
|
43
43
|
TICK_LABEL_OFFSET,
|
|
44
|
+
TICK_LINE_HEIGHT_FACTOR,
|
|
44
45
|
TOP_PAD_EXTRA_NARROW,
|
|
45
46
|
TOP_PAD_NARROW_MAX,
|
|
46
47
|
X_AXIS_BAND_HEIGHT,
|
|
48
|
+
X_AXIS_ROTATED_EXTENT_CAP,
|
|
47
49
|
X_AXIS_TITLE_BAND,
|
|
48
50
|
X_AXIS_TITLE_BAND_ROTATED,
|
|
49
51
|
} from './metrics';
|
|
@@ -173,6 +173,22 @@ export const X_AXIS_BAND_HEIGHT = 26;
|
|
|
173
173
|
export const X_AXIS_TITLE_BAND = 22;
|
|
174
174
|
export const X_AXIS_TITLE_BAND_ROTATED = 20;
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Ceiling on the vertical extent reserved for rotated x tick labels. Labels
|
|
178
|
+
* whose rotated projection exceeds this clip at the reservation edge, so the
|
|
179
|
+
* angle ladder (engine layout/axes/rotation) avoids -90° for labels longer
|
|
180
|
+
* than this.
|
|
181
|
+
*/
|
|
182
|
+
export const X_AXIS_ROTATED_EXTENT_CAP = 120;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Line-height multiplier for a tick label's text ribbon. Shared by the
|
|
186
|
+
* rotated-extent reservation below and the engine's rotation collision test
|
|
187
|
+
* (layout/axes/rotation) so the reserved space and the fit threshold can't
|
|
188
|
+
* drift apart.
|
|
189
|
+
*/
|
|
190
|
+
export const TICK_LINE_HEIGHT_FACTOR = 1.2;
|
|
191
|
+
|
|
176
192
|
export interface XAxisExtentInput {
|
|
177
193
|
labels: string[];
|
|
178
194
|
tickAngle?: number;
|
|
@@ -199,7 +215,7 @@ export function computeXAxisExtentFromLabels(input: XAxisExtentInput): number {
|
|
|
199
215
|
// lineHeight*|cos θ| (the label's own height, which still contributes
|
|
200
216
|
// unless the text is fully vertical). Measuring only the sin term
|
|
201
217
|
// under-reserved space, letting rotated ticks spill into the source line.
|
|
202
|
-
const lineHeight = input.tickFontSize *
|
|
218
|
+
const lineHeight = input.tickFontSize * TICK_LINE_HEIGHT_FACTOR;
|
|
203
219
|
// Small gap between the axis line and the top of the rotated label band.
|
|
204
220
|
// Mirrors the tick-mark-to-label offset used for flat labels; keeps the
|
|
205
221
|
// reservation matched to the drawn footprint across Blink and WebKit,
|
|
@@ -207,7 +223,7 @@ export function computeXAxisExtentFromLabels(input: XAxisExtentInput): number {
|
|
|
207
223
|
const AXIS_TO_LABEL_GAP = 3;
|
|
208
224
|
const rotatedHeight = Math.min(
|
|
209
225
|
maxLabelWidth * Math.sin(angleRad) + lineHeight * Math.cos(angleRad) + AXIS_TO_LABEL_GAP,
|
|
210
|
-
|
|
226
|
+
X_AXIS_ROTATED_EXTENT_CAP,
|
|
211
227
|
);
|
|
212
228
|
return input.hasTitle ? rotatedHeight + X_AXIS_TITLE_BAND_ROTATED : rotatedHeight;
|
|
213
229
|
}
|