@opendata-ai/openchart-core 8.0.0-rc.9 → 8.1.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/README.md +1 -1
- package/dist/index.d.ts +403 -45
- package/dist/index.js +50 -18
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +1 -2
- package/schema/chart.schema.json +375 -69
- package/schema/table.schema.json +35 -1
- package/schema/vizspec.schema.json +992 -171
- package/src/accessibility/__tests__/alt-text.test.ts +0 -110
- package/src/accessibility/__tests__/aria.test.ts +0 -125
- package/src/accessibility/alt-text.ts +0 -155
- package/src/accessibility/aria.ts +0 -75
- package/src/accessibility/index.ts +0 -6
- package/src/colors/__tests__/colorblind.test.ts +0 -75
- package/src/colors/__tests__/contrast.test.ts +0 -119
- package/src/colors/__tests__/palettes.test.ts +0 -74
- package/src/colors/colorblind.ts +0 -131
- package/src/colors/contrast.ts +0 -143
- package/src/colors/index.ts +0 -36
- package/src/colors/palettes.ts +0 -206
- package/src/helpers/__tests__/deep-merge.test.ts +0 -54
- package/src/helpers/__tests__/spec-builders.test.ts +0 -338
- package/src/helpers/deep-merge.ts +0 -29
- package/src/helpers/spec-builders.ts +0 -479
- package/src/index.ts +0 -215
- package/src/labels/__tests__/collision.test.ts +0 -310
- package/src/labels/collision.ts +0 -249
- package/src/labels/index.ts +0 -13
- package/src/layout/__tests__/chrome.test.ts +0 -208
- package/src/layout/__tests__/text-measure.test.ts +0 -57
- package/src/layout/chrome.ts +0 -470
- package/src/layout/index.ts +0 -18
- package/src/layout/text-measure.ts +0 -404
- package/src/layout/text-wrap.ts +0 -93
- package/src/locale/__tests__/format.test.ts +0 -606
- package/src/locale/format.ts +0 -459
- package/src/locale/index.ts +0 -21
- package/src/responsive/__tests__/breakpoints.test.ts +0 -146
- package/src/responsive/__tests__/metrics.test.ts +0 -52
- package/src/responsive/breakpoints.ts +0 -177
- package/src/responsive/index.ts +0 -53
- package/src/responsive/metrics.ts +0 -299
- package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
- package/src/schema/__tests__/schema.test.ts +0 -179
- package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
- package/src/styles/animation.css +0 -213
- package/src/styles/base.css +0 -102
- package/src/styles/chrome.css +0 -174
- package/src/styles/dark.css +0 -44
- package/src/styles/graph.css +0 -135
- package/src/styles/index.css +0 -28
- package/src/styles/keyframes.css +0 -151
- package/src/styles/legend.css +0 -18
- package/src/styles/reduced-motion.css +0 -67
- package/src/styles/series-search.css +0 -153
- package/src/styles/sparkline.css +0 -16
- package/src/styles/table-animation.css +0 -93
- package/src/styles/table.css +0 -398
- package/src/styles/token-definitions.ts +0 -199
- package/src/styles/tokens.css +0 -156
- package/src/styles/tooltip.css +0 -126
- package/src/styles/you-draw-it.css +0 -126
- package/src/theme/__tests__/dark-mode.test.ts +0 -232
- package/src/theme/__tests__/defaults.test.ts +0 -73
- package/src/theme/__tests__/resolve.test.ts +0 -277
- package/src/theme/dark-mode.ts +0 -258
- package/src/theme/defaults.ts +0 -110
- package/src/theme/index.ts +0 -8
- package/src/theme/presets.ts +0 -64
- package/src/theme/resolve.ts +0 -312
- package/src/types/__tests__/encoding.test.ts +0 -368
- package/src/types/__tests__/gradient.test.ts +0 -95
- package/src/types/__tests__/spec.test.ts +0 -499
- package/src/types/encoding.ts +0 -280
- package/src/types/events.ts +0 -236
- package/src/types/index.ts +0 -290
- package/src/types/layout.ts +0 -2024
- package/src/types/schema-roots.ts +0 -32
- package/src/types/spec.ts +0 -2925
- package/src/types/table.ts +0 -108
- package/src/types/theme.ts +0 -209
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Responsive breakpoints and layout strategies.
|
|
3
|
-
*
|
|
4
|
-
* Width breakpoints:
|
|
5
|
-
* - compact: < 400px (mobile, small embeds)
|
|
6
|
-
* - medium: 400-700px (tablet, sidebars)
|
|
7
|
-
* - full: > 700px (desktop, full-width)
|
|
8
|
-
*
|
|
9
|
-
* Height classes:
|
|
10
|
-
* - cramped: < 200px (dashboard widgets, thumbnails)
|
|
11
|
-
* - short: 200-350px (embedded panels, short containers)
|
|
12
|
-
* - normal: > 350px (standard containers)
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
// Width breakpoint type and detection
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
|
|
19
|
-
/** Responsive breakpoint based on container width. */
|
|
20
|
-
export type Breakpoint = 'compact' | 'medium' | 'full';
|
|
21
|
-
|
|
22
|
-
/** Breakpoint thresholds in pixels. */
|
|
23
|
-
export const BREAKPOINT_COMPACT_MAX = 400;
|
|
24
|
-
export const BREAKPOINT_MEDIUM_MAX = 700;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Determine the breakpoint for a given container width.
|
|
28
|
-
*/
|
|
29
|
-
export function getBreakpoint(width: number): Breakpoint {
|
|
30
|
-
if (width < BREAKPOINT_COMPACT_MAX) return 'compact';
|
|
31
|
-
if (width <= BREAKPOINT_MEDIUM_MAX) return 'medium';
|
|
32
|
-
return 'full';
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
// Height class type and detection
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
|
|
39
|
-
/** Height classification based on container height. */
|
|
40
|
-
export type HeightClass = 'cramped' | 'short' | 'normal';
|
|
41
|
-
|
|
42
|
-
/** Height class thresholds in pixels. */
|
|
43
|
-
export const HEIGHT_CRAMPED_MAX = 200;
|
|
44
|
-
export const HEIGHT_SHORT_MAX = 350;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Determine the height class for a given container height.
|
|
48
|
-
*/
|
|
49
|
-
export function getHeightClass(height: number): HeightClass {
|
|
50
|
-
if (height < HEIGHT_CRAMPED_MAX) return 'cramped';
|
|
51
|
-
if (height <= HEIGHT_SHORT_MAX) return 'short';
|
|
52
|
-
return 'normal';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
// Layout strategy
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
|
|
59
|
-
/** Label display mode at a given breakpoint. */
|
|
60
|
-
export type LabelMode = 'all' | 'important' | 'none';
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Legend position at a given breakpoint. `'top-left'` is a map-only overlay
|
|
64
|
-
* position: the legend floats inside the map area's top-left corner (with its
|
|
65
|
-
* own backdrop) instead of reserving a block of figure height. Charts treat
|
|
66
|
-
* it as the default position.
|
|
67
|
-
*/
|
|
68
|
-
export type LegendPosition = 'top' | 'top-left' | 'right' | 'bottom' | 'bottom-right' | 'inline';
|
|
69
|
-
|
|
70
|
-
/** Annotation position strategy. */
|
|
71
|
-
export type AnnotationPosition = 'inline' | 'tooltip-only';
|
|
72
|
-
|
|
73
|
-
/** Axis label density (controls tick count reduction). */
|
|
74
|
-
export type AxisLabelDensity = 'full' | 'reduced' | 'minimal';
|
|
75
|
-
|
|
76
|
-
/** Chrome display mode based on available height. */
|
|
77
|
-
export type ChromeMode = 'full' | 'compact' | 'hidden';
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Layout strategy defining how the visualization adapts to available space.
|
|
81
|
-
* Returned by getLayoutStrategy() based on width breakpoint and height class.
|
|
82
|
-
*/
|
|
83
|
-
export interface LayoutStrategy {
|
|
84
|
-
/** How data labels are displayed. */
|
|
85
|
-
labelMode: LabelMode;
|
|
86
|
-
/** Where the legend is positioned. */
|
|
87
|
-
legendPosition: LegendPosition;
|
|
88
|
-
/** How annotations are displayed. */
|
|
89
|
-
annotationPosition: AnnotationPosition;
|
|
90
|
-
/** Axis tick density. */
|
|
91
|
-
axisLabelDensity: AxisLabelDensity;
|
|
92
|
-
/** Chrome display mode: full, compact (title only), or hidden. */
|
|
93
|
-
chromeMode: ChromeMode;
|
|
94
|
-
/** Max fraction of container height for legend (0-1). -1 means unlimited. */
|
|
95
|
-
legendMaxHeight: number;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Get the base layout strategy for a width breakpoint.
|
|
100
|
-
*/
|
|
101
|
-
function getWidthStrategy(breakpoint: Breakpoint): LayoutStrategy {
|
|
102
|
-
switch (breakpoint) {
|
|
103
|
-
case 'compact':
|
|
104
|
-
return {
|
|
105
|
-
labelMode: 'none',
|
|
106
|
-
legendPosition: 'top',
|
|
107
|
-
annotationPosition: 'tooltip-only',
|
|
108
|
-
axisLabelDensity: 'minimal',
|
|
109
|
-
chromeMode: 'full',
|
|
110
|
-
legendMaxHeight: -1,
|
|
111
|
-
};
|
|
112
|
-
case 'medium':
|
|
113
|
-
return {
|
|
114
|
-
labelMode: 'important',
|
|
115
|
-
legendPosition: 'top',
|
|
116
|
-
annotationPosition: 'inline',
|
|
117
|
-
axisLabelDensity: 'reduced',
|
|
118
|
-
chromeMode: 'full',
|
|
119
|
-
legendMaxHeight: -1,
|
|
120
|
-
};
|
|
121
|
-
case 'full':
|
|
122
|
-
return {
|
|
123
|
-
labelMode: 'all',
|
|
124
|
-
legendPosition: 'right',
|
|
125
|
-
annotationPosition: 'inline',
|
|
126
|
-
axisLabelDensity: 'full',
|
|
127
|
-
chromeMode: 'full',
|
|
128
|
-
legendMaxHeight: -1,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Apply height constraints to a width-based strategy.
|
|
135
|
-
* Short containers compress chrome and cap legend height.
|
|
136
|
-
* Cramped containers hide chrome and labels entirely.
|
|
137
|
-
*/
|
|
138
|
-
function applyHeightConstraints(
|
|
139
|
-
strategy: LayoutStrategy,
|
|
140
|
-
heightClass: HeightClass,
|
|
141
|
-
): LayoutStrategy {
|
|
142
|
-
if (heightClass === 'normal') return strategy;
|
|
143
|
-
|
|
144
|
-
if (heightClass === 'cramped') {
|
|
145
|
-
return {
|
|
146
|
-
...strategy,
|
|
147
|
-
chromeMode: 'hidden',
|
|
148
|
-
legendMaxHeight: 0,
|
|
149
|
-
labelMode: 'none',
|
|
150
|
-
annotationPosition: 'tooltip-only',
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// short
|
|
155
|
-
return {
|
|
156
|
-
...strategy,
|
|
157
|
-
chromeMode: 'compact',
|
|
158
|
-
legendMaxHeight: 0.15,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Get the layout strategy for a given breakpoint and height class.
|
|
164
|
-
*
|
|
165
|
-
* Compact: minimal chrome, no inline labels, legend on top, reduced axes.
|
|
166
|
-
* Medium: moderate labels, legend on top, reduced axes.
|
|
167
|
-
* Full: all labels, legend on right, full axes.
|
|
168
|
-
*
|
|
169
|
-
* Height constraints further reduce chrome and legend when container is short.
|
|
170
|
-
*/
|
|
171
|
-
export function getLayoutStrategy(
|
|
172
|
-
breakpoint: Breakpoint,
|
|
173
|
-
heightClass: HeightClass = 'normal',
|
|
174
|
-
): LayoutStrategy {
|
|
175
|
-
const base = getWidthStrategy(breakpoint);
|
|
176
|
-
return applyHeightConstraints(base, heightClass);
|
|
177
|
-
}
|
package/src/responsive/index.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Responsive module barrel export.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
export type {
|
|
6
|
-
AnnotationPosition,
|
|
7
|
-
AxisLabelDensity,
|
|
8
|
-
Breakpoint,
|
|
9
|
-
ChromeMode,
|
|
10
|
-
HeightClass,
|
|
11
|
-
LabelMode,
|
|
12
|
-
LayoutStrategy,
|
|
13
|
-
LegendPosition,
|
|
14
|
-
} from './breakpoints';
|
|
15
|
-
export {
|
|
16
|
-
BREAKPOINT_COMPACT_MAX,
|
|
17
|
-
BREAKPOINT_MEDIUM_MAX,
|
|
18
|
-
getBreakpoint,
|
|
19
|
-
getHeightClass,
|
|
20
|
-
getLayoutStrategy,
|
|
21
|
-
HEIGHT_CRAMPED_MAX,
|
|
22
|
-
HEIGHT_SHORT_MAX,
|
|
23
|
-
} from './breakpoints';
|
|
24
|
-
export type { XAxisExtentInput } from './metrics';
|
|
25
|
-
export {
|
|
26
|
-
AXIS_TITLE_GAP,
|
|
27
|
-
AXIS_TITLE_OFFSET_COMPACT,
|
|
28
|
-
AXIS_TITLE_OFFSET_DEFAULT,
|
|
29
|
-
AXIS_TITLE_TRAILING_PAD,
|
|
30
|
-
axisTitleOffset,
|
|
31
|
-
computeXAxisExtentFromLabels,
|
|
32
|
-
getAxisTitleOffset,
|
|
33
|
-
HPAD_COMPACT_FRACTION,
|
|
34
|
-
HPAD_COMPACT_MIN,
|
|
35
|
-
LABEL_GAP_COMPACT,
|
|
36
|
-
LABEL_GAP_DEFAULT,
|
|
37
|
-
LABEL_GAP_NARROW_MAX,
|
|
38
|
-
MAX_LEFT_LABEL_FRACTION_COMPACT,
|
|
39
|
-
MAX_LEFT_LABEL_FRACTION_DEFAULT,
|
|
40
|
-
MAX_LEFT_LABEL_FRACTION_MEDIUM,
|
|
41
|
-
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
|
|
42
|
-
maxRotatedLabelWidth,
|
|
43
|
-
NARROW_VIEWPORT_MAX,
|
|
44
|
-
TICK_LABEL_OFFSET,
|
|
45
|
-
TICK_LINE_HEIGHT_FACTOR,
|
|
46
|
-
TOP_PAD_EXTRA_NARROW,
|
|
47
|
-
TOP_PAD_NARROW_MAX,
|
|
48
|
-
truncateRotatedLabel,
|
|
49
|
-
X_AXIS_BAND_HEIGHT,
|
|
50
|
-
X_AXIS_ROTATED_EXTENT_CAP,
|
|
51
|
-
X_AXIS_TITLE_BAND,
|
|
52
|
-
X_AXIS_TITLE_BAND_ROTATED,
|
|
53
|
-
} from './metrics';
|
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pixel-level responsive metrics for chart layout.
|
|
3
|
-
*
|
|
4
|
-
* These are the numeric tuning values that correspond to the semantic decisions
|
|
5
|
-
* in LayoutStrategy. Centralizing them here ensures the engine and renderer
|
|
6
|
-
* always agree on the same thresholds and offsets without duplicating raw numbers.
|
|
7
|
-
*
|
|
8
|
-
* LayoutStrategy is semantic (what to show), this module is metric (how much space).
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { estimateTextWidth } from '../layout/text-measure';
|
|
12
|
-
import { BREAKPOINT_COMPACT_MAX } from './breakpoints';
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// Y-axis title offsets
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
/** Distance from the chart edge to the rotated y-axis title center (compact viewports). */
|
|
19
|
-
export const AXIS_TITLE_OFFSET_COMPACT = 35;
|
|
20
|
-
|
|
21
|
-
/** Distance from the chart edge to the rotated y-axis title center (standard viewports). */
|
|
22
|
-
export const AXIS_TITLE_OFFSET_DEFAULT = 45;
|
|
23
|
-
|
|
24
|
-
/** Returns the y-axis title offset appropriate for the given container width. */
|
|
25
|
-
export function getAxisTitleOffset(width: number): number {
|
|
26
|
-
return width < BREAKPOINT_COMPACT_MAX ? AXIS_TITLE_OFFSET_COMPACT : AXIS_TITLE_OFFSET_DEFAULT;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Horizontal padding
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* On compact viewports axis titles and tick labels tolerate closer container edges
|
|
35
|
-
* than chrome text (title, subtitle). Reduce horizontal padding to reclaim space.
|
|
36
|
-
*/
|
|
37
|
-
export const HPAD_COMPACT_FRACTION = 0.5;
|
|
38
|
-
|
|
39
|
-
/** Minimum horizontal padding regardless of scaling. */
|
|
40
|
-
export const HPAD_COMPACT_MIN = 4;
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// Tick label offset
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Horizontal gap between the chart edge and the near edge of y-axis tick labels.
|
|
48
|
-
* The engine uses this to reserve right-axis margin; the renderer uses it to position labels.
|
|
49
|
-
* Both must agree on this value — do not change one without the other.
|
|
50
|
-
*/
|
|
51
|
-
export const TICK_LABEL_OFFSET = 6;
|
|
52
|
-
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Axis title trailing padding
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Extra padding beyond half-glyph height added to the rotated axis title margin on
|
|
59
|
-
* standard (non-compact) viewports. Omitted on compact viewports to save space.
|
|
60
|
-
*/
|
|
61
|
-
export const AXIS_TITLE_TRAILING_PAD = 4;
|
|
62
|
-
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
// Axis title gap
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Visible breathing room between the widest tick label's far edge and the
|
|
69
|
-
* rotated y-axis title's near edge.
|
|
70
|
-
*
|
|
71
|
-
* The title is rotated -90deg and anchored at its center, so its glyph box
|
|
72
|
-
* extends half the title font size toward the tick labels. To keep the gap
|
|
73
|
-
* constant regardless of title font size, callers add that half-glyph on top
|
|
74
|
-
* of this value (see axisTitleOffset() below). At the old fixed gap of 14 the
|
|
75
|
-
* clearance silently shrank as the font grew (14 - 11 = 3px at body size 21),
|
|
76
|
-
* which let large-font titles collide with the tick labels.
|
|
77
|
-
*
|
|
78
|
-
* Used in both the engine (dimensions.ts margin reservation) and the renderer
|
|
79
|
-
* (axes.ts title placement) via axisTitleOffset(). Both must agree on this value.
|
|
80
|
-
*/
|
|
81
|
-
export const AXIS_TITLE_GAP = 7;
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Computes the distance from the chart edge to the rotated y-axis title's center.
|
|
85
|
-
*
|
|
86
|
-
* The title center must sit far enough left that, after accounting for the
|
|
87
|
-
* widest tick label and the title's own half-glyph height, AXIS_TITLE_GAP of
|
|
88
|
-
* visible clearance remains. Falls back to the viewport-minimum offset when the
|
|
89
|
-
* dynamic value would be smaller (short tick labels on wide containers).
|
|
90
|
-
*
|
|
91
|
-
* When `inline` is true the y-tick labels render inside the chart area (above
|
|
92
|
-
* their gridlines) rather than in a left gutter, so the title only needs to
|
|
93
|
-
* clear the chart edge — no tick-label width and no viewport-minimum floor,
|
|
94
|
-
* both of which would otherwise leave a dead gap between the title and the plot.
|
|
95
|
-
*
|
|
96
|
-
* Shared by the engine (margin reservation) and the renderer (title placement)
|
|
97
|
-
* so the reserved space always matches where the title is drawn.
|
|
98
|
-
*/
|
|
99
|
-
export function axisTitleOffset(
|
|
100
|
-
maxTickLabelWidth: number,
|
|
101
|
-
titleFontSize: number,
|
|
102
|
-
width: number,
|
|
103
|
-
inline = false,
|
|
104
|
-
): number {
|
|
105
|
-
const halfTitleGlyph = Math.ceil(titleFontSize / 2);
|
|
106
|
-
if (inline) {
|
|
107
|
-
// No gutter tick labels to clear: title edge + gap + half-glyph only.
|
|
108
|
-
return TICK_LABEL_OFFSET + AXIS_TITLE_GAP + halfTitleGlyph;
|
|
109
|
-
}
|
|
110
|
-
const dynamic = TICK_LABEL_OFFSET + maxTickLabelWidth + AXIS_TITLE_GAP + halfTitleGlyph;
|
|
111
|
-
return Math.max(dynamic, getAxisTitleOffset(width));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
// Narrow viewport threshold (between compact and medium)
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Width below which "narrow" adjustments apply: extra iOS Safari top padding and
|
|
120
|
-
* tighter category label gaps. Sits between compact (< 400) and medium (400–700).
|
|
121
|
-
* Not a semantic breakpoint — a layout heuristic for narrow-but-not-compact containers.
|
|
122
|
-
*/
|
|
123
|
-
export const NARROW_VIEWPORT_MAX = 500;
|
|
124
|
-
|
|
125
|
-
// ---------------------------------------------------------------------------
|
|
126
|
-
// Top padding (iOS Safari address bar / notch clearance)
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
|
|
129
|
-
/** Extra top padding added below NARROW_VIEWPORT_MAX to clear iOS Safari browser chrome. */
|
|
130
|
-
export const TOP_PAD_EXTRA_NARROW = 10;
|
|
131
|
-
|
|
132
|
-
/** @deprecated Use NARROW_VIEWPORT_MAX instead. */
|
|
133
|
-
export const TOP_PAD_NARROW_MAX = NARROW_VIEWPORT_MAX;
|
|
134
|
-
|
|
135
|
-
// ---------------------------------------------------------------------------
|
|
136
|
-
// Category label gaps (left-axis bar/dot charts)
|
|
137
|
-
// ---------------------------------------------------------------------------
|
|
138
|
-
|
|
139
|
-
/** Gap between category label text and chart edge on narrow viewports (< NARROW_VIEWPORT_MAX). */
|
|
140
|
-
export const LABEL_GAP_COMPACT = 8;
|
|
141
|
-
|
|
142
|
-
/** Gap between category label text and chart edge on standard viewports. */
|
|
143
|
-
export const LABEL_GAP_DEFAULT = 12;
|
|
144
|
-
|
|
145
|
-
/** @deprecated Use NARROW_VIEWPORT_MAX instead. */
|
|
146
|
-
export const LABEL_GAP_NARROW_MAX = NARROW_VIEWPORT_MAX;
|
|
147
|
-
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
|
-
// Max left-axis label fraction
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Width threshold for the medium left-label fraction cap.
|
|
154
|
-
* Sits between medium (400-700) and full (> 700) to prevent wide category labels
|
|
155
|
-
* from consuming too much horizontal space at mid-range widths.
|
|
156
|
-
*/
|
|
157
|
-
export const MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX = 600;
|
|
158
|
-
|
|
159
|
-
/** Max fraction of container width reservable for left category labels (compact). */
|
|
160
|
-
export const MAX_LEFT_LABEL_FRACTION_COMPACT = 0.45;
|
|
161
|
-
|
|
162
|
-
/** Max fraction of container width reservable for left category labels (mid-range). */
|
|
163
|
-
export const MAX_LEFT_LABEL_FRACTION_MEDIUM = 0.55;
|
|
164
|
-
|
|
165
|
-
/** Max fraction of container width reservable for left category labels (standard). */
|
|
166
|
-
export const MAX_LEFT_LABEL_FRACTION_DEFAULT = 1;
|
|
167
|
-
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
// X-axis extent (height below chart area)
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
|
|
172
|
-
export const X_AXIS_BAND_HEIGHT = 26;
|
|
173
|
-
export const X_AXIS_TITLE_BAND = 22;
|
|
174
|
-
export const X_AXIS_TITLE_BAND_ROTATED = 20;
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Ceiling on the vertical extent reserved for rotated x tick labels. The angle
|
|
178
|
-
* ladder (engine layout/axes/rotation) avoids -90° for labels longer than this.
|
|
179
|
-
*
|
|
180
|
-
* The cap bounds the RESERVATION, so a label whose rotated projection exceeds it
|
|
181
|
-
* must be truncated to fit — see `truncateRotatedLabel`. Reserving a capped band
|
|
182
|
-
* while drawing the full string is what let long ticks spill past the axis and
|
|
183
|
-
* collide with the source line.
|
|
184
|
-
*/
|
|
185
|
-
export const X_AXIS_ROTATED_EXTENT_CAP = 120;
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Line-height multiplier for a tick label's text ribbon. Shared by the
|
|
189
|
-
* rotated-extent reservation below and the engine's rotation collision test
|
|
190
|
-
* (layout/axes/rotation) so the reserved space and the fit threshold can't
|
|
191
|
-
* drift apart.
|
|
192
|
-
*/
|
|
193
|
-
export const TICK_LINE_HEIGHT_FACTOR = 1.2;
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Gap between the axis line and the top of the rotated label band. Mirrors the
|
|
197
|
-
* tick-mark-to-label offset used for flat labels; keeps the reservation matched
|
|
198
|
-
* to the drawn footprint across Blink and WebKit, whose rotated-glyph metrics
|
|
199
|
-
* differ by a couple of pixels. Shared by the extent reservation and the
|
|
200
|
-
* truncation budget so they stay in lockstep.
|
|
201
|
-
*/
|
|
202
|
-
const AXIS_TO_LABEL_GAP = 3;
|
|
203
|
-
|
|
204
|
-
export interface XAxisExtentInput {
|
|
205
|
-
labels: string[];
|
|
206
|
-
tickAngle?: number;
|
|
207
|
-
hasTitle: boolean;
|
|
208
|
-
tickFontSize: number;
|
|
209
|
-
tickFontWeight: number;
|
|
210
|
-
xAxisHeight?: number;
|
|
211
|
-
measure?: (text: string, fontSize: number, fontWeight: number) => number;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function computeXAxisExtentFromLabels(input: XAxisExtentInput): number {
|
|
215
|
-
const measure = input.measure ?? estimateTextWidth;
|
|
216
|
-
const baseHeight = input.xAxisHeight ?? X_AXIS_BAND_HEIGHT;
|
|
217
|
-
|
|
218
|
-
if (input.tickAngle && Math.abs(input.tickAngle) > 10) {
|
|
219
|
-
const angleRad = Math.abs(input.tickAngle) * (Math.PI / 180);
|
|
220
|
-
let maxLabelWidth = 40;
|
|
221
|
-
for (const label of input.labels) {
|
|
222
|
-
const w = measure(label, input.tickFontSize, input.tickFontWeight);
|
|
223
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
224
|
-
}
|
|
225
|
-
// Vertical extent of a rotated label = the projection of its bounding box
|
|
226
|
-
// onto the vertical axis: textWidth*|sin θ| (the tilted length) plus
|
|
227
|
-
// lineHeight*|cos θ| (the label's own height, which still contributes
|
|
228
|
-
// unless the text is fully vertical). Measuring only the sin term
|
|
229
|
-
// under-reserved space, letting rotated ticks spill into the source line.
|
|
230
|
-
const lineHeight = input.tickFontSize * TICK_LINE_HEIGHT_FACTOR;
|
|
231
|
-
const rotatedHeight = Math.min(
|
|
232
|
-
maxLabelWidth * Math.sin(angleRad) + lineHeight * Math.cos(angleRad) + AXIS_TO_LABEL_GAP,
|
|
233
|
-
X_AXIS_ROTATED_EXTENT_CAP,
|
|
234
|
-
);
|
|
235
|
-
return input.hasTitle ? rotatedHeight + X_AXIS_TITLE_BAND_ROTATED : rotatedHeight;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const tickExtent = 4 + input.tickFontSize + Math.max(11, Math.ceil(input.tickFontSize * 0.7));
|
|
239
|
-
const flatHeight = Math.max(baseHeight, tickExtent);
|
|
240
|
-
return input.hasTitle ? flatHeight + X_AXIS_TITLE_BAND : flatHeight;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* The widest a rotated tick label may be before its vertical projection exceeds
|
|
245
|
-
* `X_AXIS_ROTATED_EXTENT_CAP`.
|
|
246
|
-
*
|
|
247
|
-
* Inverts the rotated-extent formula in `computeXAxisExtentFromLabels`:
|
|
248
|
-
*
|
|
249
|
-
* extent = width*sin(θ) + lineHeight*cos(θ) + gap
|
|
250
|
-
* width = (cap - lineHeight*cos(θ) - gap) / sin(θ)
|
|
251
|
-
*
|
|
252
|
-
* Deriving it from the same terms (rather than hardcoding a second number) keeps
|
|
253
|
-
* the truncation budget and the space reservation locked together — if one
|
|
254
|
-
* changes, the other follows.
|
|
255
|
-
*/
|
|
256
|
-
export function maxRotatedLabelWidth(tickAngle: number, tickFontSize: number): number {
|
|
257
|
-
const angleRad = Math.abs(tickAngle) * (Math.PI / 180);
|
|
258
|
-
const sin = Math.sin(angleRad);
|
|
259
|
-
// A flat (or near-flat) label has no vertical projection to bound.
|
|
260
|
-
if (sin <= 0.001) return Number.POSITIVE_INFINITY;
|
|
261
|
-
const lineHeight = tickFontSize * TICK_LINE_HEIGHT_FACTOR;
|
|
262
|
-
const usable = X_AXIS_ROTATED_EXTENT_CAP - lineHeight * Math.cos(angleRad) - AXIS_TO_LABEL_GAP;
|
|
263
|
-
return Math.max(0, usable / sin);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Truncate a rotated tick label with an ellipsis so it fits inside the capped
|
|
268
|
-
* reservation. Returns the label unchanged when it already fits.
|
|
269
|
-
*
|
|
270
|
-
* Binary-search on character count against the real measure function, so it
|
|
271
|
-
* respects proportional fonts rather than assuming a fixed character width.
|
|
272
|
-
*/
|
|
273
|
-
export function truncateRotatedLabel(
|
|
274
|
-
label: string,
|
|
275
|
-
tickAngle: number,
|
|
276
|
-
tickFontSize: number,
|
|
277
|
-
tickFontWeight: number,
|
|
278
|
-
measure: (text: string, fontSize: number, fontWeight?: number) => number = estimateTextWidth,
|
|
279
|
-
): string {
|
|
280
|
-
const budget = maxRotatedLabelWidth(tickAngle, tickFontSize);
|
|
281
|
-
if (!Number.isFinite(budget)) return label;
|
|
282
|
-
if (measure(label, tickFontSize, tickFontWeight) <= budget) return label;
|
|
283
|
-
|
|
284
|
-
const ELLIPSIS = '…';
|
|
285
|
-
// Nothing sensible fits: keep a single character + ellipsis rather than
|
|
286
|
-
// returning an empty tick.
|
|
287
|
-
let lo = 0;
|
|
288
|
-
let hi = label.length;
|
|
289
|
-
while (lo < hi) {
|
|
290
|
-
const mid = Math.ceil((lo + hi) / 2);
|
|
291
|
-
const candidate = label.slice(0, mid) + ELLIPSIS;
|
|
292
|
-
if (measure(candidate, tickFontSize, tickFontWeight) <= budget) {
|
|
293
|
-
lo = mid;
|
|
294
|
-
} else {
|
|
295
|
-
hi = mid - 1;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return lo > 0 ? label.slice(0, lo).trimEnd() + ELLIPSIS : ELLIPSIS;
|
|
299
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Smoke test that the generated LLM-facing artifacts cover every mark type.
|
|
3
|
-
*
|
|
4
|
-
* `scripts/generate-schema.mjs` and `scripts/generate-llms.mjs` emit the
|
|
5
|
-
* committed `packages/core/schema/*.json` and root `llms.txt`. This test
|
|
6
|
-
* asserts the two artifacts reference all 16 declared mark types, so adding a
|
|
7
|
-
* mark to MARK_TYPES without regenerating (or a generator regression that drops
|
|
8
|
-
* a mark) is caught here rather than shipping a schema/doc that silently omits
|
|
9
|
-
* it. CI's `check:generated` step guarantees the artifacts are fresh; this test
|
|
10
|
-
* guarantees they are complete.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { readFileSync } from 'node:fs';
|
|
14
|
-
import { dirname, join } from 'node:path';
|
|
15
|
-
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import { describe, expect, it } from 'vitest';
|
|
17
|
-
import { MARK_TYPES } from '../../types/spec';
|
|
18
|
-
|
|
19
|
-
const coreRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
20
|
-
const repoRoot = join(coreRoot, '..', '..');
|
|
21
|
-
|
|
22
|
-
const chartSchema = readFileSync(join(coreRoot, 'schema', 'chart.schema.json'), 'utf8');
|
|
23
|
-
const llmsTxt = readFileSync(join(repoRoot, 'llms.txt'), 'utf8');
|
|
24
|
-
|
|
25
|
-
describe('generated artifacts cover every mark type', () => {
|
|
26
|
-
it('chart.schema.json references all 16 mark types', () => {
|
|
27
|
-
for (const mark of MARK_TYPES) {
|
|
28
|
-
expect(chartSchema, `chart.schema.json is missing mark "${mark}"`).toContain(`"${mark}"`);
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('llms.txt documents all 16 mark types in the grammar table', () => {
|
|
33
|
-
for (const mark of MARK_TYPES) {
|
|
34
|
-
// The generated mark table renders each mark as an inline-code cell.
|
|
35
|
-
expect(llmsTxt, `llms.txt is missing mark "${mark}"`).toContain(`\`${mark}\``);
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('MARK_TYPES has exactly 16 entries (the surface this plan targets)', () => {
|
|
40
|
-
expect(MARK_TYPES.size).toBe(16);
|
|
41
|
-
});
|
|
42
|
-
});
|