@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1
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 +142 -7
- package/dist/index.js +16182 -10032
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
- package/src/__tests__/annotation-behavior.test.ts +721 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/dimensions.test.ts +49 -4
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +120 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +222 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +312 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +117 -21
- package/src/compile/size-scale.ts +139 -0
- package/src/compile/spec-sugar.ts +758 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +315 -103
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
- package/src/compiler/__tests__/validate.test.ts +156 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/normalize.ts +150 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +664 -18
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +621 -0
- package/src/geo/compile-map.ts +856 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +45 -0
- package/src/geo/types.ts +24 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +28 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +30 -2
- package/src/layout/dimensions.ts +178 -95
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +86 -35
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +126 -14
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/legend/wrap.ts +8 -0
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- package/src/transforms/aggregate.ts +2 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar heatmap mark computation.
|
|
3
|
+
*
|
|
4
|
+
* GitHub-style layout: weeks as columns, weekdays as rows, month labels
|
|
5
|
+
* above each band, Mon/Wed/Fri weekday labels on the left. Multi-year data
|
|
6
|
+
* partitions into one full-year band per calendar year, stacked vertically
|
|
7
|
+
* with a year label per band; every band shares the figure's single color
|
|
8
|
+
* scale (built once from the full dataset by computeScales).
|
|
9
|
+
*
|
|
10
|
+
* The calendar owns its geometry: no positional scales or axes. All date
|
|
11
|
+
* math runs in UTC (d3-time utc* intervals) so the layout is deterministic
|
|
12
|
+
* regardless of the host machine's timezone -- ISO date strings like
|
|
13
|
+
* "2024-01-15" parse as UTC midnight, and a local-time week interval would
|
|
14
|
+
* shift them across day boundaries in western timezones.
|
|
15
|
+
*
|
|
16
|
+
* Days inside a band's year with no data row (or a non-numeric value)
|
|
17
|
+
* render as empty cells filled with the theme's achromatic surface tint
|
|
18
|
+
* (colors.annotationFill), visually distinct from the chromatic scale
|
|
19
|
+
* minimum in both light and dark modes. Days padding the first/last week
|
|
20
|
+
* but belonging to a neighboring year render nothing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
Encoding,
|
|
25
|
+
Mark,
|
|
26
|
+
MarkAria,
|
|
27
|
+
Rect,
|
|
28
|
+
RectMark,
|
|
29
|
+
ResolvedTheme,
|
|
30
|
+
TextMarkLayout,
|
|
31
|
+
} from '@opendata-ai/openchart-core';
|
|
32
|
+
import { defaultNumberFormatter, formatDate } from '@opendata-ai/openchart-core';
|
|
33
|
+
import { type CountableTimeInterval, utcDay, utcMonday, utcSunday } from 'd3-time';
|
|
34
|
+
|
|
35
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
36
|
+
import type { ResolvedScales } from '../../layout/scales';
|
|
37
|
+
import { getSequentialColor } from '../utils';
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Constants
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Minimum day-cell size in pixels (the "never shrink marks below legibility"
|
|
45
|
+
* floor, extending the never-shrink-text house rule to marks). When the
|
|
46
|
+
* container is too narrow for 53 legible columns the grid keeps this floor
|
|
47
|
+
* and overflows the chart area instead of shrinking further; hosts that need
|
|
48
|
+
* every column visible at phone widths wrap the figure in a horizontal
|
|
49
|
+
* scroll container.
|
|
50
|
+
*/
|
|
51
|
+
export const MIN_CELL_SIZE = 7;
|
|
52
|
+
|
|
53
|
+
/** Maximum grid step (cell + gap) so huge containers stay dense. */
|
|
54
|
+
const MAX_STEP = 20;
|
|
55
|
+
|
|
56
|
+
/** Minimum grid step: the cell floor plus a 1px gap. */
|
|
57
|
+
const MIN_STEP = MIN_CELL_SIZE + 1;
|
|
58
|
+
|
|
59
|
+
/** Vertical gap between stacked year bands. */
|
|
60
|
+
const BAND_GAP = 18;
|
|
61
|
+
|
|
62
|
+
/** Gap between the weekday labels and the first week column. */
|
|
63
|
+
const WEEKDAY_LABEL_GAP = 6;
|
|
64
|
+
|
|
65
|
+
/** Default corner radius for day cells (markDef.cellRadius overrides). */
|
|
66
|
+
const DEFAULT_CELL_RADIUS = 1;
|
|
67
|
+
|
|
68
|
+
/** Weekday labels shown on the left (day-of-week numbers, 0 = Sunday). */
|
|
69
|
+
const WEEKDAY_LABELS: Array<{ dow: number; label: string }> = [
|
|
70
|
+
{ dow: 1, label: 'Mon' },
|
|
71
|
+
{ dow: 3, label: 'Wed' },
|
|
72
|
+
{ dow: 5, label: 'Fri' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Abbreviated month labels, January first. */
|
|
76
|
+
const MONTH_LABELS = [
|
|
77
|
+
'Jan',
|
|
78
|
+
'Feb',
|
|
79
|
+
'Mar',
|
|
80
|
+
'Apr',
|
|
81
|
+
'May',
|
|
82
|
+
'Jun',
|
|
83
|
+
'Jul',
|
|
84
|
+
'Aug',
|
|
85
|
+
'Sep',
|
|
86
|
+
'Oct',
|
|
87
|
+
'Nov',
|
|
88
|
+
'Dec',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Date helpers
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** Parse a raw field value to a UTC-floored day, or null when unparseable. */
|
|
96
|
+
export function parseUtcDay(value: unknown): Date | null {
|
|
97
|
+
if (value == null) return null;
|
|
98
|
+
const date = value instanceof Date ? value : new Date(value as string | number);
|
|
99
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
100
|
+
return utcDay.floor(date);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** ISO yyyy-mm-dd key for a UTC day. */
|
|
104
|
+
function isoDayKey(day: Date): string {
|
|
105
|
+
return day.toISOString().slice(0, 10);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Week interval for a weekStart option. */
|
|
109
|
+
function weekInterval(weekStart: 'monday' | 'sunday'): CountableTimeInterval {
|
|
110
|
+
return weekStart === 'sunday' ? utcSunday : utcMonday;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Weekday row index (0..6) for a UTC day under a weekStart option. */
|
|
114
|
+
export function weekdayRow(day: Date, weekStart: 'monday' | 'sunday'): number {
|
|
115
|
+
const startDow = weekStart === 'sunday' ? 0 : 1;
|
|
116
|
+
return (day.getUTCDay() - startDow + 7) % 7;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Public API
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compute calendar heatmap marks from a normalized chart spec.
|
|
125
|
+
*
|
|
126
|
+
* Emits one RectMark per day of every calendar year present in the data
|
|
127
|
+
* (days without data get the empty-cell surface tint) plus TextMarkLayout
|
|
128
|
+
* marks for month, weekday, and (multi-year only) year labels.
|
|
129
|
+
*/
|
|
130
|
+
export function computeCalendarMarks(
|
|
131
|
+
spec: NormalizedChartSpec,
|
|
132
|
+
scales: ResolvedScales,
|
|
133
|
+
chartArea: Rect,
|
|
134
|
+
theme: ResolvedTheme,
|
|
135
|
+
): Mark[] {
|
|
136
|
+
const encoding = spec.encoding as Encoding;
|
|
137
|
+
const xEnc = encoding.x;
|
|
138
|
+
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
139
|
+
if (!xEnc || !colorEnc) return [];
|
|
140
|
+
|
|
141
|
+
const weekStart = spec.markDef.weekStart ?? 'monday';
|
|
142
|
+
const week = weekInterval(weekStart);
|
|
143
|
+
const cellRadius = spec.markDef.cellRadius ?? DEFAULT_CELL_RADIUS;
|
|
144
|
+
|
|
145
|
+
// Index rows by UTC day. Unparseable dates and non-numeric values were
|
|
146
|
+
// rejected (or warned about) at validation; anything that still slips
|
|
147
|
+
// through renders as an empty cell rather than corrupting the grid.
|
|
148
|
+
const dayData = new Map<string, { row: Record<string, unknown>; value: number }>();
|
|
149
|
+
const years = new Set<number>();
|
|
150
|
+
for (const row of spec.data) {
|
|
151
|
+
const day = parseUtcDay(row[xEnc.field]);
|
|
152
|
+
if (!day) continue;
|
|
153
|
+
years.add(day.getUTCFullYear());
|
|
154
|
+
const value = Number(row[colorEnc.field]);
|
|
155
|
+
if (!Number.isFinite(value)) continue;
|
|
156
|
+
dayData.set(isoDayKey(day), { row: row as Record<string, unknown>, value });
|
|
157
|
+
}
|
|
158
|
+
if (years.size === 0) return [];
|
|
159
|
+
|
|
160
|
+
const sortedYears = Array.from(years).sort((a, b) => a - b);
|
|
161
|
+
|
|
162
|
+
// Per-band week-column counts. A band always spans its full calendar year
|
|
163
|
+
// (Jan 1 through Dec 31), so partial-year data keeps the familiar 53-column
|
|
164
|
+
// silhouette and missing days read as gaps rather than a shifted grid.
|
|
165
|
+
const bandColumns = sortedYears.map((year) => {
|
|
166
|
+
const firstWeek = week.floor(new Date(Date.UTC(year, 0, 1)));
|
|
167
|
+
const lastDay = new Date(Date.UTC(year, 11, 31));
|
|
168
|
+
return week.count(firstWeek, lastDay) + 1;
|
|
169
|
+
});
|
|
170
|
+
const maxColumns = Math.max(...bandColumns);
|
|
171
|
+
|
|
172
|
+
// -------------------------------------------------------------------------
|
|
173
|
+
// Geometry
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
const labelFont = theme.fonts.sizes.small;
|
|
176
|
+
const gutter = Math.ceil(labelFont * 2.6) + WEEKDAY_LABEL_GAP;
|
|
177
|
+
const monthRow = Math.ceil(labelFont * 1.3) + 2;
|
|
178
|
+
const bands = sortedYears.length;
|
|
179
|
+
const showYearLabels = bands > 1;
|
|
180
|
+
|
|
181
|
+
const availWidth = Math.max(0, chartArea.width - gutter);
|
|
182
|
+
const stepFromWidth = availWidth / maxColumns;
|
|
183
|
+
const availHeight = Math.max(0, chartArea.height - bands * monthRow - (bands - 1) * BAND_GAP);
|
|
184
|
+
const stepFromHeight = availHeight / (7 * bands);
|
|
185
|
+
// Square cells only: the calendar idiom reads as a grid of days, and
|
|
186
|
+
// wide cells would misalign weekday rows across stacked year bands. At
|
|
187
|
+
// narrow widths the step clamps to the MIN_CELL_SIZE floor instead of
|
|
188
|
+
// going sub-legible (the grid then overflows to the right).
|
|
189
|
+
const step = Math.min(
|
|
190
|
+
MAX_STEP,
|
|
191
|
+
Math.max(MIN_STEP, Math.floor(Math.min(stepFromWidth, stepFromHeight))),
|
|
192
|
+
);
|
|
193
|
+
const cellGap = step >= 14 ? 3 : step >= 10 ? 2 : 1;
|
|
194
|
+
const cell = step - cellGap;
|
|
195
|
+
|
|
196
|
+
const gridWidth = maxColumns * step - cellGap;
|
|
197
|
+
const bandHeight = monthRow + 7 * step - cellGap;
|
|
198
|
+
const totalHeight = bands * bandHeight + (bands - 1) * BAND_GAP;
|
|
199
|
+
const originX = chartArea.x + gutter + Math.max(0, (availWidth - gridWidth) / 2);
|
|
200
|
+
const originY = chartArea.y + Math.max(0, (chartArea.height - totalHeight) / 2);
|
|
201
|
+
|
|
202
|
+
// Month label thinning: when four week columns can't fit a three-letter
|
|
203
|
+
// label, keep every other month so labels never collide.
|
|
204
|
+
const monthLabelWidth = labelFont * 2.2;
|
|
205
|
+
const monthLabelEvery = step * 4.34 < monthLabelWidth + 4 ? 2 : 1;
|
|
206
|
+
|
|
207
|
+
const labelBaseline = labelFont; // baseline offset from the band top
|
|
208
|
+
|
|
209
|
+
const cellMarks: RectMark[] = [];
|
|
210
|
+
const labelMarks: TextMarkLayout[] = [];
|
|
211
|
+
|
|
212
|
+
const labelAria: MarkAria = { decorative: true };
|
|
213
|
+
const labelStyle = {
|
|
214
|
+
fill: theme.colors.axis,
|
|
215
|
+
fontSize: labelFont,
|
|
216
|
+
fontWeight: theme.fonts.weights.normal,
|
|
217
|
+
fontFamily: theme.fonts.family,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// -------------------------------------------------------------------------
|
|
221
|
+
// Bands
|
|
222
|
+
// -------------------------------------------------------------------------
|
|
223
|
+
const calendarFmt = defaultNumberFormatter();
|
|
224
|
+
for (let b = 0; b < bands; b++) {
|
|
225
|
+
const year = sortedYears[b];
|
|
226
|
+
const bandTop = originY + b * (bandHeight + BAND_GAP);
|
|
227
|
+
const gridTop = bandTop + monthRow;
|
|
228
|
+
const yearStart = new Date(Date.UTC(year, 0, 1));
|
|
229
|
+
const yearEnd = new Date(Date.UTC(year + 1, 0, 1));
|
|
230
|
+
const firstWeek = week.floor(yearStart);
|
|
231
|
+
|
|
232
|
+
// Year label (multi-year only), in the weekday gutter column.
|
|
233
|
+
if (showYearLabels) {
|
|
234
|
+
labelMarks.push({
|
|
235
|
+
type: 'textMark',
|
|
236
|
+
x: chartArea.x,
|
|
237
|
+
y: bandTop + labelBaseline,
|
|
238
|
+
text: String(year),
|
|
239
|
+
...labelStyle,
|
|
240
|
+
fill: theme.colors.text,
|
|
241
|
+
fontWeight: theme.fonts.weights.semibold,
|
|
242
|
+
textAnchor: 'start',
|
|
243
|
+
data: {},
|
|
244
|
+
aria: labelAria,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Month labels above the grid, at the column containing the 1st.
|
|
249
|
+
for (let m = 0; m < 12; m += monthLabelEvery) {
|
|
250
|
+
const col = week.count(firstWeek, new Date(Date.UTC(year, m, 1)));
|
|
251
|
+
labelMarks.push({
|
|
252
|
+
type: 'textMark',
|
|
253
|
+
x: originX + col * step,
|
|
254
|
+
y: bandTop + labelBaseline,
|
|
255
|
+
text: MONTH_LABELS[m],
|
|
256
|
+
...labelStyle,
|
|
257
|
+
textAnchor: 'start',
|
|
258
|
+
data: {},
|
|
259
|
+
aria: labelAria,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Weekday labels (Mon/Wed/Fri) left of the grid, centered on their row.
|
|
264
|
+
for (const { dow, label } of WEEKDAY_LABELS) {
|
|
265
|
+
const row = (dow - (weekStart === 'sunday' ? 0 : 1) + 7) % 7;
|
|
266
|
+
labelMarks.push({
|
|
267
|
+
type: 'textMark',
|
|
268
|
+
x: originX - WEEKDAY_LABEL_GAP,
|
|
269
|
+
y: gridTop + row * step + cell / 2 + labelFont * 0.35,
|
|
270
|
+
text: label,
|
|
271
|
+
...labelStyle,
|
|
272
|
+
textAnchor: 'end',
|
|
273
|
+
data: {},
|
|
274
|
+
aria: labelAria,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Day cells: every day of the calendar year.
|
|
279
|
+
for (const day of utcDay.range(yearStart, yearEnd)) {
|
|
280
|
+
const col = week.count(firstWeek, day);
|
|
281
|
+
const row = weekdayRow(day, weekStart);
|
|
282
|
+
const key = isoDayKey(day);
|
|
283
|
+
const datum = dayData.get(key);
|
|
284
|
+
const dateLabel = formatDate(day, undefined, 'day');
|
|
285
|
+
|
|
286
|
+
if (datum) {
|
|
287
|
+
cellMarks.push({
|
|
288
|
+
type: 'rect',
|
|
289
|
+
key,
|
|
290
|
+
x: originX + col * step,
|
|
291
|
+
y: gridTop + row * step,
|
|
292
|
+
width: cell,
|
|
293
|
+
height: cell,
|
|
294
|
+
fill: getSequentialColor(scales, datum.value),
|
|
295
|
+
cornerRadius: cellRadius,
|
|
296
|
+
data: datum.row,
|
|
297
|
+
aria: {
|
|
298
|
+
label: `${dateLabel}, ${colorEnc.field}: ${calendarFmt(datum.value)}`,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
} else {
|
|
302
|
+
cellMarks.push({
|
|
303
|
+
type: 'rect',
|
|
304
|
+
key,
|
|
305
|
+
x: originX + col * step,
|
|
306
|
+
y: gridTop + row * step,
|
|
307
|
+
width: cell,
|
|
308
|
+
height: cell,
|
|
309
|
+
fill: theme.colors.annotationFill,
|
|
310
|
+
cornerRadius: cellRadius,
|
|
311
|
+
data: {},
|
|
312
|
+
aria: { decorative: true },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return [...cellMarks, ...labelMarks];
|
|
319
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar heatmap chart module.
|
|
3
|
+
*
|
|
4
|
+
* Exports the calendar renderer and computation function. Calendars emit
|
|
5
|
+
* RectMark day cells plus decorative TextMarkLayout month/weekday/year
|
|
6
|
+
* labels; there are no positional scales or axes (the compile pipeline
|
|
7
|
+
* suppresses them, arc precedent). The quantitative color encoding drives
|
|
8
|
+
* both the cell fills and the default-on continuous color legend.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ChartRenderer } from '../registry';
|
|
12
|
+
import { computeCalendarMarks } from './compute';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Calendar chart renderer
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Calendar heatmap renderer.
|
|
20
|
+
*
|
|
21
|
+
* Produces one rect per day of each calendar year in the data, plus text
|
|
22
|
+
* marks for the month/weekday/year labels.
|
|
23
|
+
*/
|
|
24
|
+
export const calendarRenderer: ChartRenderer = (spec, scales, chartArea, _strategy, theme) => {
|
|
25
|
+
return computeCalendarMarks(spec, scales, chartArea, theme);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Public exports
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export { computeCalendarMarks, MIN_CELL_SIZE, parseUtcDay, weekdayRow } from './compute';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { buildD3Formatter } from '@opendata-ai/openchart-core';
|
|
2
3
|
import { describe, expect, it } from 'vitest';
|
|
3
4
|
import type { NormalizedChartSpec } from '../../../compiler/types';
|
|
4
5
|
import { computeScales } from '../../../layout/scales';
|
|
@@ -55,7 +56,9 @@ function makeGroupedColumnSpec(): NormalizedChartSpec {
|
|
|
55
56
|
],
|
|
56
57
|
encoding: {
|
|
57
58
|
x: { field: 'month', type: 'nominal' },
|
|
58
|
-
|
|
59
|
+
// Grouped is opt-out since v8 (stacked is now the default): stamp
|
|
60
|
+
// stack: null explicitly so this fixture stays grouped.
|
|
61
|
+
y: { field: 'sales', type: 'quantitative', stack: null },
|
|
59
62
|
color: { field: 'region', type: 'nominal' },
|
|
60
63
|
},
|
|
61
64
|
chrome: {},
|
|
@@ -193,7 +196,7 @@ describe('computeColumnMarks', () => {
|
|
|
193
196
|
});
|
|
194
197
|
});
|
|
195
198
|
|
|
196
|
-
describe('grouped columns (
|
|
199
|
+
describe('grouped columns (stack: null)', () => {
|
|
197
200
|
it('produces marks for all data rows', () => {
|
|
198
201
|
const spec = makeGroupedColumnSpec();
|
|
199
202
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
@@ -351,7 +354,7 @@ describe('computeColumnLabels', () => {
|
|
|
351
354
|
const spec = makeSimpleColumnSpec();
|
|
352
355
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
353
356
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
354
|
-
const labels = computeColumnLabels(marks, chartArea, 'auto', '$,.0f');
|
|
357
|
+
const labels = computeColumnLabels(marks, chartArea, 'auto', buildD3Formatter('$,.0f'));
|
|
355
358
|
|
|
356
359
|
const texts = labels.map((l) => l.text);
|
|
357
360
|
expect(texts).toContain('$120');
|
|
@@ -379,7 +382,7 @@ describe('computeColumnLabels', () => {
|
|
|
379
382
|
};
|
|
380
383
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
381
384
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
382
|
-
const labels = computeColumnLabels(marks, chartArea, 'all', '$,.2~f');
|
|
385
|
+
const labels = computeColumnLabels(marks, chartArea, 'all', buildD3Formatter('$,.2~f'));
|
|
383
386
|
|
|
384
387
|
const texts = labels.map((l) => l.text);
|
|
385
388
|
expect(texts).toContain('$3.1');
|
|
@@ -407,7 +410,7 @@ describe('computeColumnLabels', () => {
|
|
|
407
410
|
};
|
|
408
411
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
409
412
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
410
|
-
const labels = computeColumnLabels(marks, chartArea, 'all', '$,.2~fT');
|
|
413
|
+
const labels = computeColumnLabels(marks, chartArea, 'all', buildD3Formatter('$,.2~fT'));
|
|
411
414
|
|
|
412
415
|
const texts = labels.map((l) => l.text);
|
|
413
416
|
expect(texts).toContain('$3.75T');
|
|
@@ -418,7 +421,7 @@ describe('computeColumnLabels', () => {
|
|
|
418
421
|
const spec = makeSimpleColumnSpec();
|
|
419
422
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
420
423
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
421
|
-
const labels = computeColumnLabels(marks, chartArea, 'auto', '.0f%');
|
|
424
|
+
const labels = computeColumnLabels(marks, chartArea, 'auto', buildD3Formatter('.0f%'));
|
|
422
425
|
|
|
423
426
|
const texts = labels.map((l) => l.text);
|
|
424
427
|
expect(texts).toContain('120%');
|
|
@@ -432,7 +435,7 @@ describe('computeColumnLabels', () => {
|
|
|
432
435
|
|
|
433
436
|
describe('stack modes', () => {
|
|
434
437
|
function makeStackedSpec(
|
|
435
|
-
stackMode: boolean | 'zero' | 'normalize' | 'center' | null,
|
|
438
|
+
stackMode: boolean | 'zero' | 'normalize' | 'center' | null | undefined,
|
|
436
439
|
): NormalizedChartSpec {
|
|
437
440
|
return {
|
|
438
441
|
markType: 'bar',
|
|
@@ -523,4 +526,15 @@ describe('stack modes', () => {
|
|
|
523
526
|
expect(mark.stackGroup).toBeUndefined();
|
|
524
527
|
}
|
|
525
528
|
});
|
|
529
|
+
|
|
530
|
+
it('undefined (omitted) stacks by default (v8 VL-aligned default)', () => {
|
|
531
|
+
const spec = makeStackedSpec(undefined);
|
|
532
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
533
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
534
|
+
|
|
535
|
+
expect(marks.length).toBe(4);
|
|
536
|
+
for (const mark of marks) {
|
|
537
|
+
expect(mark.stackGroup).toBeDefined();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
526
540
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RectMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { buildD3Formatter } from '@opendata-ai/openchart-core';
|
|
2
3
|
import { describe, expect, it } from 'vitest';
|
|
3
4
|
import { computeColumnLabels } from '../labels';
|
|
4
5
|
|
|
@@ -98,7 +99,81 @@ describe('computeColumnLabels positioning', () => {
|
|
|
98
99
|
});
|
|
99
100
|
|
|
100
101
|
it('applies labelFormat to numeric values', () => {
|
|
101
|
-
const labels = computeColumnLabels(
|
|
102
|
+
const labels = computeColumnLabels(
|
|
103
|
+
[makeMark(0, 1234)],
|
|
104
|
+
chartArea,
|
|
105
|
+
'all',
|
|
106
|
+
buildD3Formatter(',.0f'),
|
|
107
|
+
);
|
|
102
108
|
expect(labels[0].text).toBe('1,234');
|
|
103
109
|
});
|
|
104
110
|
});
|
|
111
|
+
|
|
112
|
+
describe('computeColumnLabels stacked segments', () => {
|
|
113
|
+
/** One segment of a stacked column, positioned by its top edge. */
|
|
114
|
+
function makeStackedMark(y: number, height: number, fill: string, value: number): RectMark {
|
|
115
|
+
return {
|
|
116
|
+
type: 'rect',
|
|
117
|
+
x: 0,
|
|
118
|
+
y,
|
|
119
|
+
width: 60,
|
|
120
|
+
height,
|
|
121
|
+
fill,
|
|
122
|
+
stackGroup: 'Cat0',
|
|
123
|
+
data: { category: 'Cat0', value },
|
|
124
|
+
aria: { label: `Cat0: ${value}` },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it('centers the label inside its own segment, not above it', () => {
|
|
129
|
+
// Above a stacked segment's top edge is the next segment up, not the
|
|
130
|
+
// background, so the label must stay within its own band.
|
|
131
|
+
const mark = makeStackedMark(100, 80, '#0e7490', 33);
|
|
132
|
+
const labels = computeColumnLabels([mark], chartArea, 'all');
|
|
133
|
+
|
|
134
|
+
expect(labels).toHaveLength(1);
|
|
135
|
+
expect(labels[0].y).toBeGreaterThan(mark.y);
|
|
136
|
+
expect(labels[0].y).toBeLessThan(mark.y + mark.height);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('picks a fill that contrasts with its own segment', () => {
|
|
140
|
+
// A dark segment takes white text; a light segment takes near-black. The
|
|
141
|
+
// old code tinted the text with the segment's own series color, which put
|
|
142
|
+
// colored text on a colored fill.
|
|
143
|
+
const dark = computeColumnLabels([makeStackedMark(100, 80, '#0e7490', 33)], chartArea, 'all');
|
|
144
|
+
const light = computeColumnLabels([makeStackedMark(100, 80, '#e2e8f0', 33)], chartArea, 'all');
|
|
145
|
+
|
|
146
|
+
expect(dark[0].style.fill).toBe('#ffffff');
|
|
147
|
+
expect(light[0].style.fill).toBe('#111111');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('hides the label when the segment is too short to hold it', () => {
|
|
151
|
+
// A ~4% segment (Nuclear in the energy-mix demo) can't fit a 10px label
|
|
152
|
+
// without spilling into its neighbours.
|
|
153
|
+
const labels = computeColumnLabels([makeStackedMark(100, 6, '#0e7490', 4)], chartArea, 'all');
|
|
154
|
+
|
|
155
|
+
expect(labels).toHaveLength(1);
|
|
156
|
+
expect(labels[0].visible).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('an explicit labels.color still wins over the contrast pick', () => {
|
|
160
|
+
const labels = computeColumnLabels(
|
|
161
|
+
[makeStackedMark(100, 80, '#0e7490', 33)],
|
|
162
|
+
chartArea,
|
|
163
|
+
'all',
|
|
164
|
+
undefined,
|
|
165
|
+
undefined,
|
|
166
|
+
undefined,
|
|
167
|
+
'#ff00ff',
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(labels[0].style.fill).toBe('#ff00ff');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('leaves unstacked columns labeling above the bar', () => {
|
|
174
|
+
const mark = makeMark(0, 20);
|
|
175
|
+
const labels = computeColumnLabels([mark], chartArea, 'all');
|
|
176
|
+
|
|
177
|
+
expect(labels[0].y).toBeLessThan(mark.y);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -95,12 +95,8 @@ export function computeColumnMarks(
|
|
|
95
95
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
96
96
|
|
|
97
97
|
if (needsStacking) {
|
|
98
|
-
// stack: true/'zero'/'normalize'/'center' -> stacked
|
|
99
|
-
const stackEnabled =
|
|
100
|
-
yChannel.stack === true ||
|
|
101
|
-
yChannel.stack === 'zero' ||
|
|
102
|
-
yChannel.stack === 'normalize' ||
|
|
103
|
-
yChannel.stack === 'center';
|
|
98
|
+
// stack: undefined/true/'zero'/'normalize'/'center' -> stacked (default); null/false -> grouped
|
|
99
|
+
const stackEnabled = yChannel.stack !== null && yChannel.stack !== false;
|
|
104
100
|
|
|
105
101
|
if (!stackEnabled) {
|
|
106
102
|
marks = computeGroupedColumns(
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Mark } from '@opendata-ai/openchart-core';
|
|
8
|
+
import { resolveFieldFormatter } from '../../format/field-format';
|
|
8
9
|
import type { ChartRenderer } from '../registry';
|
|
9
10
|
import { computeColumnMarks } from './compute';
|
|
10
11
|
import { computeColumnLabels } from './labels';
|
|
@@ -13,20 +14,27 @@ import { computeColumnLabels } from './labels';
|
|
|
13
14
|
// Column chart renderer
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
|
|
16
|
-
export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
|
|
17
|
+
export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
|
|
17
18
|
const marks = computeColumnMarks(spec, scales, chartArea, strategy);
|
|
18
19
|
|
|
19
20
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
21
|
const valueField =
|
|
21
22
|
spec.encoding?.y && 'field' in spec.encoding.y ? spec.encoding.y.field : undefined;
|
|
23
|
+
const labelFormatter = resolveFieldFormatter({
|
|
24
|
+
surfaceFormat: spec.labels.format,
|
|
25
|
+
channelFormat:
|
|
26
|
+
spec.encoding?.y && 'format' in spec.encoding.y ? spec.encoding.y.format : undefined,
|
|
27
|
+
values: valueField ? spec.data.map((r) => r[valueField]) : [],
|
|
28
|
+
});
|
|
22
29
|
const labels = computeColumnLabels(
|
|
23
30
|
marks,
|
|
24
31
|
chartArea,
|
|
25
32
|
spec.labels.density,
|
|
26
|
-
|
|
33
|
+
labelFormatter,
|
|
27
34
|
spec.labels.prefix,
|
|
28
35
|
valueField,
|
|
29
36
|
spec.labels.color,
|
|
37
|
+
theme.isDark,
|
|
30
38
|
spec.labels.fontSize,
|
|
31
39
|
spec.labels.suffix,
|
|
32
40
|
);
|