@fundar/data-chart-telling 0.0.37 → 0.0.39
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.
|
@@ -104,25 +104,46 @@
|
|
|
104
104
|
/**
|
|
105
105
|
* Pixel-space collision fix-up for end-point labels: resolves each
|
|
106
106
|
* label's pixel Y through the live y-scale, then declutters those pixels
|
|
107
|
-
* to find how far each needs to move to clear its neighbours
|
|
108
|
-
* `
|
|
109
|
-
*
|
|
107
|
+
* to find how far each needs to move to clear its neighbours and stay
|
|
108
|
+
* within the plot's own top/bottom edge (`extraDy` — applies even to a
|
|
109
|
+
* lone label with no neighbours, if its own value sits close enough to
|
|
110
|
+
* the edge). `rank`/`lanesUsed`/`runSize` come from `laneAssignment`,
|
|
111
|
+
* which turns the decluttered runs into lane assignments.
|
|
110
112
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* Horizontal placement (`aligned`/`reach`/`laneReach`/`dir`) is separate:
|
|
114
|
+
* every label reaches out to the same shared lane column, not just the
|
|
115
|
+
* ones in its own cluster, so the whole set of end-of-line labels lines
|
|
116
|
+
* up on one horizontal line. A label gets a connector whenever either of
|
|
117
|
+
* these moved it off its natural position — see the markup below.
|
|
118
|
+
*
|
|
119
|
+
* That shared column's direction can itself flip from `dir` to the right,
|
|
120
|
+
* but only ever away from the left: a leftward fan (`dir === -1`) runs
|
|
121
|
+
* into the Y axis's own tick labels once every point sits pinned against
|
|
122
|
+
* that same edge (e.g. a scroll/scrub caller's first visible point), with
|
|
123
|
+
* nowhere to reserve room without growing a margin purely to sit on top of
|
|
124
|
+
* them. The right has no such fixed content by default, so flipping there
|
|
125
|
+
* instead — for every aligned label at once — avoids the collision
|
|
126
|
+
* without needing any extra margin at all. The mirror case is deliberately
|
|
127
|
+
* left alone: a rightward fan's point is *always* pinned at the scale's
|
|
128
|
+
* own right edge by construction (`available` below would read ~0 for it
|
|
129
|
+
* too, same as the left), so the same check would flip the common case
|
|
130
|
+
* right back into the plot over its own lines — margin growth (see
|
|
131
|
+
* `Plot.svelte`'s `labelMargin`) is what actually handles that side.
|
|
116
132
|
*/
|
|
117
133
|
const declutteredOffsets = $derived.by(() => {
|
|
118
|
-
const map = new SvelteMap<
|
|
134
|
+
const map = new SvelteMap<
|
|
135
|
+
string,
|
|
136
|
+
{ extraDy: number; aligned: boolean; rank: number; reach: number; laneReach: number; dir: number }
|
|
137
|
+
>();
|
|
119
138
|
if (lastPoints.length < 2) return map;
|
|
120
139
|
const yScale = plot.scales.y?.fn;
|
|
121
140
|
if (!yScale) return map;
|
|
141
|
+
const xScale = plot.scales.x?.fn;
|
|
122
142
|
const minGap = Math.max(16, fontSize * 1.4);
|
|
123
143
|
const items = lastPoints.map(({ series: s, lastRow }) => ({
|
|
124
144
|
name: s.name,
|
|
125
145
|
pos: Number(yScale(Number(resolveAccessor(s.y)(lastRow)))),
|
|
146
|
+
px: xScale ? Number(xScale(resolveAccessor(s.x)(lastRow))) : undefined,
|
|
126
147
|
}));
|
|
127
148
|
const sorted = [...items].sort((a, b) => a.pos - b.pos);
|
|
128
149
|
// Same content-box source as `watchLabelOverflow`'s `bounds()` above.
|
|
@@ -137,6 +158,24 @@
|
|
|
137
158
|
const globalLanes = anyGrouped ? Math.max(...ranks.map((r) => r.lanesUsed)) : 1;
|
|
138
159
|
const reach = elbowGap + (globalLanes - 1) * laneStep + labelGap;
|
|
139
160
|
|
|
161
|
+
let effectiveDir = dir;
|
|
162
|
+
if (anyGrouped && dir === -1) {
|
|
163
|
+
// -Infinity (not +Infinity) when the x-scale isn't resolved yet on an
|
|
164
|
+
// early render — defaults to flipped until proven safe not to be,
|
|
165
|
+
// rather than the other way around. Defaulting to "don't flip" here
|
|
166
|
+
// briefly rendered the full leftward fan with no available-space data
|
|
167
|
+
// at all, which `watchLabelOverflow` could catch and lock in via its
|
|
168
|
+
// own monotonic growth before a later render corrected it — flipping
|
|
169
|
+
// right is never wrong even when it turns out to be unnecessary (nothing
|
|
170
|
+
// fixed lives there to run into), so there's no equivalent risk on
|
|
171
|
+
// that side to defend against.
|
|
172
|
+
const roomsLeft = sorted
|
|
173
|
+
.filter((item): item is typeof item & { px: number } => item.px != null)
|
|
174
|
+
.map((item) => item.px - numericMargins.left);
|
|
175
|
+
const availableLeft = roomsLeft.length > 0 ? Math.min(...roomsLeft) : -Infinity;
|
|
176
|
+
if (availableLeft < reach) effectiveDir = 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
140
179
|
sorted.forEach((item, i) => {
|
|
141
180
|
map.set(item.name, {
|
|
142
181
|
extraDy: finals[i] - originals[i],
|
|
@@ -144,6 +183,7 @@
|
|
|
144
183
|
rank: ranks[i].rank,
|
|
145
184
|
reach,
|
|
146
185
|
laneReach: elbowGap + ranks[i].rank * laneStep,
|
|
186
|
+
dir: effectiveDir,
|
|
147
187
|
});
|
|
148
188
|
});
|
|
149
189
|
return map;
|
|
@@ -151,22 +191,38 @@
|
|
|
151
191
|
</script>
|
|
152
192
|
|
|
153
193
|
<g bind:this={groupEl}>
|
|
194
|
+
<!-- Nothing renders before both scales resolve: `declutteredOffsets` itself
|
|
195
|
+
only needs the y-scale, so an early render with the x-scale still
|
|
196
|
+
missing would otherwise fall back to plain `baseDx`, un-flipped — for a
|
|
197
|
+
long 'end'-anchored label that's a real, if brief, overflow past the
|
|
198
|
+
edge, and `watchLabelOverflow` can catch and lock that in via its own
|
|
199
|
+
monotonic growth before a later, correctly-positioned render arrives. -->
|
|
200
|
+
{#if plot.scales.x?.fn && plot.scales.y?.fn}
|
|
154
201
|
{#each lastPoints as { series: s, lastRow, color: seriesColor } (s.name)}
|
|
155
202
|
{@const xFn = resolveAccessor(s.x)}
|
|
156
203
|
{@const yFn = resolveAccessor(s.y)}
|
|
157
204
|
{@const offset = declutteredOffsets.get(s.name)}
|
|
158
|
-
{@const extraDy = offset?.
|
|
159
|
-
{@const
|
|
205
|
+
{@const extraDy = offset?.extraDy ?? 0}
|
|
206
|
+
{@const effDir = offset?.aligned ? offset.dir : dir}
|
|
207
|
+
{@const flipped = offset?.aligned && effDir !== dir}
|
|
208
|
+
{@const labelDx = offset?.aligned ? effDir * offset.reach : baseDx}
|
|
160
209
|
{@const text = formatValue(Number(yFn(lastRow)), s.name)}
|
|
161
210
|
{@const preferredTA = resolvedTextAnchor ?? defaultTA}
|
|
211
|
+
{@const finalTA = flipped
|
|
212
|
+
? preferredTA === 'start'
|
|
213
|
+
? 'end'
|
|
214
|
+
: preferredTA === 'end'
|
|
215
|
+
? 'start'
|
|
216
|
+
: preferredTA
|
|
217
|
+
: preferredTA}
|
|
162
218
|
{@const preferredLA = font?.lineAnchor ?? defaultLA}
|
|
163
219
|
{@const disabled = disabledFor?.(s.name)}
|
|
164
220
|
{@const disabledStroke = disabledStrokeOverride(disabled)}
|
|
165
221
|
{@const disabledFill = disabledFillOverride(disabled)}
|
|
166
|
-
{#if offset?.aligned
|
|
222
|
+
{#if offset?.aligned || extraDy !== 0}
|
|
167
223
|
{@const px = Number(plot.scales.x.fn(xFn(lastRow)))}
|
|
168
224
|
{@const py = Number(plot.scales.y.fn(Number(yFn(lastRow))))}
|
|
169
|
-
{@const laneX = px +
|
|
225
|
+
{@const laneX = offset?.aligned ? px + effDir * offset.laneReach : px + labelDx}
|
|
170
226
|
{@const targetY = py + extraDy}
|
|
171
227
|
<path
|
|
172
228
|
d={`M ${px} ${py} H ${laneX} V ${targetY} H ${px + labelDx}`}
|
|
@@ -192,7 +248,7 @@
|
|
|
192
248
|
text={() => text}
|
|
193
249
|
dx={labelDx}
|
|
194
250
|
dy={(font?.dy ?? defaultDy) + extraDy}
|
|
195
|
-
textAnchor={
|
|
251
|
+
textAnchor={finalTA}
|
|
196
252
|
lineAnchor={preferredLA}
|
|
197
253
|
lineHeight={font?.lineHeight}
|
|
198
254
|
rotate={font?.rotate}
|
|
@@ -209,4 +265,5 @@
|
|
|
209
265
|
/>
|
|
210
266
|
</g>
|
|
211
267
|
{/each}
|
|
268
|
+
{/if}
|
|
212
269
|
</g>
|
|
@@ -103,10 +103,10 @@ function runBiasWeights(down, up, connected, [lo, hi]) {
|
|
|
103
103
|
* it lands entirely inside `[lo, hi]`. A run's own relative spacing is
|
|
104
104
|
* preserved as-is (just translated) whenever it already fits in `hi - lo`;
|
|
105
105
|
* only a run wider than the available space gets uniformly scaled down
|
|
106
|
-
* around its centre first.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
106
|
+
* around its centre first. A lone, unconnected position is simply clamped
|
|
107
|
+
* to the nearer bound instead — its own value never needed decluttering,
|
|
108
|
+
* but it can still sit close enough to the scale's own edge to overflow on
|
|
109
|
+
* its own.
|
|
110
110
|
*/
|
|
111
111
|
function fitRunsWithinBounds(positions, connected, [lo, hi]) {
|
|
112
112
|
const out = [...positions];
|
|
@@ -130,6 +130,9 @@ function fitRunsWithinBounds(positions, connected, [lo, hi]) {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
else {
|
|
134
|
+
out[i] = Math.min(Math.max(out[i], lo), hi);
|
|
135
|
+
}
|
|
133
136
|
i = j + 1;
|
|
134
137
|
}
|
|
135
138
|
return out;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AUTO_MARGIN_ESTIMATE } from '../../layout/plot/margins';
|
|
1
2
|
const NO_OVERFLOW = { left: 0, right: 0, top: 0, bottom: 0 };
|
|
2
3
|
/**
|
|
3
4
|
* Watches an SVG group's rendered bounding box against the plot's own
|
|
@@ -36,12 +37,21 @@ export function watchLabelOverflow(args) {
|
|
|
36
37
|
catch {
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
// An empty group (no labels currently rendered, e.g. every series is
|
|
41
|
+
// scrubbed/filtered out of view) reports getBBox() as (0,0,0,0) — the
|
|
42
|
+
// SVG origin, not "no content". Reading that literally as a box sitting
|
|
43
|
+
// at (0,0) would report overflow proportional to the *current* margins
|
|
44
|
+
// themselves, which grows them, which remounts this group against the
|
|
45
|
+
// new, larger bounds, which reports overflow again — an unbounded loop
|
|
46
|
+
// with nothing rendered to justify it.
|
|
47
|
+
const overflow = box.width === 0 && box.height === 0
|
|
48
|
+
? NO_OVERFLOW
|
|
49
|
+
: {
|
|
50
|
+
left: Math.ceil(Math.max(0, bounds.left - box.x)),
|
|
51
|
+
right: Math.ceil(Math.max(0, box.x + box.width - bounds.right)),
|
|
52
|
+
top: Math.ceil(Math.max(0, bounds.top - box.y)),
|
|
53
|
+
bottom: Math.ceil(Math.max(0, box.y + box.height - bounds.bottom))
|
|
54
|
+
};
|
|
45
55
|
attempt += 1;
|
|
46
56
|
const stable = lastReading != null &&
|
|
47
57
|
lastReading.left === overflow.left &&
|
|
@@ -85,7 +95,7 @@ export function createLabelMarginTracker(margins) {
|
|
|
85
95
|
left: Math.max(measured.left, overflow.left),
|
|
86
96
|
right: Math.max(measured.right, overflow.right),
|
|
87
97
|
top: Math.max(measured.top, overflow.top),
|
|
88
|
-
bottom: Math.max(measured.bottom, overflow.bottom)
|
|
98
|
+
bottom: Math.max(measured.bottom, overflow.bottom)
|
|
89
99
|
};
|
|
90
100
|
if (next.left !== measured.left ||
|
|
91
101
|
next.right !== measured.right ||
|
|
@@ -94,19 +104,27 @@ export function createLabelMarginTracker(margins) {
|
|
|
94
104
|
measured = next;
|
|
95
105
|
}
|
|
96
106
|
}
|
|
107
|
+
// `measured` is overflow *past* the current margin, not the total margin
|
|
108
|
+
// needed — grown on top of `AUTO_MARGIN_ESTIMATE` (the same fallback
|
|
109
|
+
// svelteplot's own 'auto' sizing is estimated by elsewhere), not the raw
|
|
110
|
+
// overflow alone, or growth would silently replace whatever room 'auto'
|
|
111
|
+
// would have reserved for the axis itself (tick labels, title) with a
|
|
112
|
+
// number that only accounts for the value label. `AUTO_MARGIN_ESTIMATE` is
|
|
113
|
+
// a fixed constant, not `numericMargins`' own already-grown value, so this
|
|
114
|
+
// can't compound into unbounded growth across repeated reports.
|
|
97
115
|
const resolvedMargins = $derived.by(() => {
|
|
98
116
|
const base = margins();
|
|
99
117
|
if (measured.left <= 0 && measured.right <= 0 && measured.top <= 0 && measured.bottom <= 0)
|
|
100
118
|
return base;
|
|
101
119
|
const out = { ...base };
|
|
102
120
|
if (measured.right > 0 && base?.right === undefined)
|
|
103
|
-
out.right = measured.right;
|
|
121
|
+
out.right = AUTO_MARGIN_ESTIMATE.right + measured.right;
|
|
104
122
|
if (measured.left > 0 && base?.left === undefined)
|
|
105
|
-
out.left = measured.left;
|
|
123
|
+
out.left = AUTO_MARGIN_ESTIMATE.left + measured.left;
|
|
106
124
|
if (measured.top > 0 && base?.top === undefined)
|
|
107
|
-
out.top = measured.top;
|
|
125
|
+
out.top = AUTO_MARGIN_ESTIMATE.top + measured.top;
|
|
108
126
|
if (measured.bottom > 0 && base?.bottom === undefined)
|
|
109
|
-
out.bottom = measured.bottom;
|
|
127
|
+
out.bottom = AUTO_MARGIN_ESTIMATE.bottom + measured.bottom;
|
|
110
128
|
return out;
|
|
111
129
|
});
|
|
112
130
|
// Bundles `resolvedMargins` with a remount key derived from `measured`,
|
|
@@ -117,11 +135,15 @@ export function createLabelMarginTracker(margins) {
|
|
|
117
135
|
// must track.
|
|
118
136
|
const plotProps = $derived.by(() => ({
|
|
119
137
|
margins: resolvedMargins,
|
|
120
|
-
marginRemountKey: JSON.stringify(measured)
|
|
138
|
+
marginRemountKey: JSON.stringify(measured)
|
|
121
139
|
}));
|
|
122
140
|
return {
|
|
123
141
|
report,
|
|
124
|
-
get resolvedMargins() {
|
|
125
|
-
|
|
142
|
+
get resolvedMargins() {
|
|
143
|
+
return resolvedMargins;
|
|
144
|
+
},
|
|
145
|
+
get plotProps() {
|
|
146
|
+
return plotProps;
|
|
147
|
+
}
|
|
126
148
|
};
|
|
127
149
|
}
|