-
-
Notifications
You must be signed in to change notification settings - Fork 628
Expand file tree
/
Copy pathprint-data-blocks.ts
More file actions
215 lines (201 loc) · 8.04 KB
/
Copy pathprint-data-blocks.ts
File metadata and controls
215 lines (201 loc) · 8.04 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
/**
* Data-driven Print Layout blocks (GH #1324): pure builders that turn a vector
* layer's attribute rows into the drawable attribute-table and chart specs of
* {@link LayoutOptions}. Row aggregation reuses the attribute Charts panel's
* compute helpers, and the atlas page-extent filter reuses the atlas bounds
* walk, so the page shows exactly what those features hold. Framework-free and
* unit-testable; the canvas drawing lives in `print-layout.ts`.
*/
import type { FeatureCollection } from "geojson";
import {
computeBar,
computeLine,
computePie,
toFiniteNumber,
type BarAggregation,
type ChartRow,
} from "./attribute-charts";
import { type AtlasBounds, type AtlasFeatureInfo } from "./print-atlas";
import { paletteColor } from "../components/panels/charts/chart-colors";
import type { DataChartData } from "./print-layout";
/** Hard ceiling on table rows drawn on the page. */
export const MAX_TABLE_ROWS = 50;
/** Default table row limit offered by the dialog. */
export const DEFAULT_TABLE_ROWS = 10;
/** Columns shown when the user has not picked any explicitly. */
export const DEFAULT_TABLE_COLUMNS = 4;
/** Reduce a feature collection to the property-bag rows the builders consume. */
export function layerRows(collection: Pick<FeatureCollection, "features">): ChartRow[] {
return collection.features.map((feature) => ({
properties: (feature.properties ?? {}) as Record<string, unknown>,
}));
}
/**
* Whether two `[west, south, east, north]` boxes overlap (touching counts).
* The two boxes need not share a longitude convention: `geometryBounds`
* returns shifted boxes (east > 180) for antimeridian-crossing features, and
* `map.getBounds()` can return unwrapped longitudes near the dateline, so the
* test also tries `b` shifted by ±360° — the same ground box, one world copy
* over. (A box that is degenerate `west > east` without being unwrapped is
* not supported, matching the print-extent tool's documented limitation.)
*/
export function boundsIntersect(a: AtlasBounds, b: AtlasBounds): boolean {
if (a[1] > b[3] || b[1] > a[3]) return false;
for (const offset of [0, -360, 360]) {
if (a[0] <= b[2] + offset && b[0] + offset <= a[2]) return true;
}
return false;
}
/**
* The rows of the features whose geometry bounding box intersects `bounds` —
* the per-page filter for atlas data blocks. Takes {@link AtlasFeatureInfo}s
* (from `collectAtlasFeatures`) rather than raw features so the per-vertex
* geometry walk runs once per layer, not once per atlas page; features
* without a usable geometry were already dropped there (they are nowhere on
* the page). A bbox test (not an exact intersection) matches how atlas pages
* themselves are framed.
*/
export function rowsWithinBounds(
features: readonly AtlasFeatureInfo[],
bounds: AtlasBounds,
): ChartRow[] {
const rows: ChartRow[] = [];
for (const info of features) {
if (!boundsIntersect(info.bounds, bounds)) continue;
rows.push({ properties: info.properties });
}
return rows;
}
/**
* Select the attribute row belonging to the current atlas coverage feature.
* `AtlasPage.sourceIndex` is stable across filtering and sorting, and the row
* array preserves the source collection's order, so the two align directly.
*/
export function rowForAtlasFeature(rows: readonly ChartRow[], sourceIndex: number): ChartRow[] {
const row = rows[sourceIndex];
return row ? [row] : [];
}
/** Format one attribute value for a table cell (blank for null/undefined). */
function cellText(value: unknown): string {
if (value === null || value === undefined) return "";
return String(value);
}
/** Whether an attribute value counts as missing for sorting purposes. */
function isMissing(value: unknown): boolean {
return value === null || value === undefined || value === "";
}
/** Numeric-aware attribute comparison; missing values sort last. */
function compareCells(a: unknown, b: unknown): number {
const aMissing = isMissing(a);
const bMissing = isMissing(b);
if (aMissing && bMissing) return 0;
if (aMissing) return 1;
if (bMissing) return -1;
const an = toFiniteNumber(a);
const bn = toFiniteNumber(b);
if (an !== null && bn !== null) return an === bn ? 0 : an < bn ? -1 : 1;
return String(a).localeCompare(String(b), undefined, { numeric: true });
}
export interface TableBlockConfig {
/** Columns to show, in order. */
columns: string[];
/** Attribute to order rows by; blank keeps the source row order. */
sortField?: string;
/** Reverse the sort (missing values always sort last). */
sortDescending?: boolean;
/** Row limit (clamped to 1..{@link MAX_TABLE_ROWS}). */
maxRows?: number;
}
export interface TableBlockData {
columns: string[];
/** Cell display strings, aligned with {@link columns}. */
rows: string[][];
/** Source rows beyond the limit (0 when everything fit). */
truncated: number;
}
/**
* Build the table block's display data: sort, cap to the row limit, and
* stringify cells. Returns null when there are no rows or no columns, so the
* dialog can skip the block entirely instead of drawing an empty panel.
*/
export function buildTableBlock(rows: ChartRow[], config: TableBlockConfig): TableBlockData | null {
if (rows.length === 0 || config.columns.length === 0) return null;
let ordered = rows;
const { sortField } = config;
if (sortField) {
const sign = config.sortDescending ? -1 : 1;
ordered = [...rows].sort((a, b) => {
const av = a.properties[sortField];
const bv = b.properties[sortField];
const cmp = compareCells(av, bv);
// Missing values stay last in both directions (same convention as the
// atlas page sort), so only present-vs-present flips with the sign.
return isMissing(av) || isMissing(bv) ? cmp : cmp * sign;
});
}
const requested = Math.trunc(config.maxRows ?? DEFAULT_TABLE_ROWS);
const limit = Number.isFinite(requested)
? Math.max(1, Math.min(MAX_TABLE_ROWS, requested))
: DEFAULT_TABLE_ROWS;
const shown = ordered.slice(0, limit);
return {
columns: config.columns,
rows: shown.map((row) => config.columns.map((column) => cellText(row.properties[column]))),
truncated: Math.max(0, ordered.length - shown.length),
};
}
export type ChartBlockType = "bar" | "pie" | "line";
export interface ChartBlockConfig {
type: ChartBlockType;
/** Grouping field (bar/pie). */
categoryField?: string;
/** How bar/pie values reduce their groups. */
aggregation?: BarAggregation;
/** Numeric field summed/averaged (bar/pie) or plotted (line). */
valueField?: string;
}
/**
* Build the chart block's drawable data from attribute rows, reusing the
* Charts panel's aggregation helpers (top-N capping and the "(other)" fold
* included) and its categorical palette. Returns null when the configuration
* is incomplete or no chartable data survives, so nothing is drawn.
*/
export function buildChartBlock(rows: ChartRow[], config: ChartBlockConfig): DataChartData | null {
if (rows.length === 0) return null;
if (config.type === "line") {
if (!config.valueField) return null;
const line = computeLine(rows, config.valueField);
if (!line) return null;
return { kind: "line", ...line, color: paletteColor(0) };
}
if (!config.categoryField) return null;
const aggregation = config.aggregation ?? "count";
const valueField = aggregation === "count" ? null : (config.valueField ?? null);
if (aggregation !== "count" && !valueField) return null;
if (config.type === "pie") {
const pie = computePie(rows, config.categoryField, aggregation, valueField);
if (!pie) return null;
return {
kind: "pie",
slices: pie.slices.map((slice, i) => ({
label: slice.label,
value: slice.value,
color: paletteColor(i),
})),
total: pie.total,
};
}
const bar = computeBar(rows, config.categoryField, aggregation, valueField);
if (!bar) return null;
return {
kind: "bar",
bars: bar.bars.map((datum, i) => ({
label: datum.label,
value: datum.value,
color: paletteColor(i),
})),
maxValue: bar.maxValue,
minValue: bar.minValue,
truncated: bar.truncated,
};
}