@lotics/ui 47.13.0 → 47.14.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/src/matrix.tsx CHANGED
@@ -1,13 +1,28 @@
1
1
  import { Children, createContext, isValidElement, useContext, useMemo, type ReactNode } from "react";
2
- import { StyleSheet, View } from "react-native";
2
+ import { ScrollView, StyleSheet, View } from "react-native";
3
3
  import { colors, withAlpha, type ColorName } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { FocusRingPressable } from "./focus_ring_pressable";
6
- import { matrixTotals, type MatrixAxisItem, type MatrixCellRef } from "./matrix_totals";
6
+ import { LEADING_GAP } from "./cell_stack";
7
+ import {
8
+ matrixHeatScale,
9
+ matrixTotals,
10
+ type MatrixAxisItem,
11
+ type MatrixCellRef,
12
+ type MatrixHeatScale,
13
+ } from "./matrix_totals";
7
14
  import { useLoticsLocale } from "./locale";
8
15
 
9
16
  type Display = "number" | "heat" | "both";
10
17
 
18
+ /** How much of the hue a wash may spend, per display. Behind a NUMBER the wash
19
+ * has to stay under the glyphs; when the colour IS the value it takes the lot. */
20
+ const HEAT_BAND: Record<Display, [number, number]> = {
21
+ both: [0.05, 0.45],
22
+ heat: [0.15, 1],
23
+ number: [0.05, 0.45],
24
+ };
25
+
11
26
  interface MatrixContextValue {
12
27
  rows: MatrixAxisItem[];
13
28
  cols: MatrixAxisItem[];
@@ -20,6 +35,10 @@ interface MatrixContextValue {
20
35
  selected: MatrixCellRef | null;
21
36
  onSelectCell?: (cell: MatrixCellRef | null) => void;
22
37
  heatBase: string;
38
+ /** Value → alpha. The cells and the legend read this ONE function. */
39
+ heatAlpha: (value: number) => number;
40
+ /** The legend's swatch alphas, sampled from `heatAlpha`. */
41
+ heatLegend: number[];
23
42
  formatValue: (n: number) => string;
24
43
  rowLabelWidth: number;
25
44
  }
@@ -32,6 +51,12 @@ export const MATRIX_COL_GAP = 3;
32
51
  * cần một khoảng với mép nền; dán sát thì nó đọc như bị cắt. Tiêu đề cột cộng
33
52
  * đúng số này vào `paddingRight` của nó để hai bên kết thúc trên một đường. */
34
53
  const MATRIX_CELL_PAD = 6;
54
+ /** Sàn bề ngang của một ô. `styles.cell` và phép tính bề ngang tối thiểu của cả
55
+ * lưới phải đọc CÙNG một con số: lệch nhau là lưới tự tin mình vừa khung trong
56
+ * khi ô đã bị bóp. */
57
+ const MATRIX_CELL_MIN = 52 + MATRIX_CELL_PAD * 2;
58
+ /** Sàn của cột Tổng — xem `styles.totalCell`. */
59
+ const MATRIX_TOTAL_MIN = 64;
35
60
 
36
61
  const MatrixContext = createContext<MatrixContextValue | null>(null);
37
62
 
@@ -56,6 +81,12 @@ export interface MatrixProps {
56
81
  onSelectCell?: (cell: MatrixCellRef | null) => void;
57
82
  /** Heat hue. Intensity is alpha within it; zero cells go neutral. Default blue. */
58
83
  heatColor?: ColorName;
84
+ /**
85
+ * How a value becomes ink. `rank` spends the band on the cells' ORDER, `linear`
86
+ * on their share of the largest cell. Default: `rank` behind numbers, `linear`
87
+ * for a pure heatmap, where the colour is the only statement of magnitude.
88
+ */
89
+ heatScale?: MatrixHeatScale;
59
90
  formatValue?: (n: number) => string;
60
91
  /** Width of the leading row-label column. Default 140 (room for long names). */
61
92
  rowLabelWidth?: number;
@@ -81,6 +112,7 @@ export function Matrix(props: MatrixProps) {
81
112
  selected = null,
82
113
  onSelectCell,
83
114
  heatColor = "blue",
115
+ heatScale,
84
116
  formatValue = defaultFormat,
85
117
  rowLabelWidth = 140,
86
118
  children,
@@ -94,11 +126,31 @@ export function Matrix(props: MatrixProps) {
94
126
  [children],
95
127
  );
96
128
 
129
+ // The band a wash may spend is decided by what the GRID draws, and the legend
130
+ // is that same scale seen from the side — so the scale is resolved once here,
131
+ // where both bands can read it, rather than in each of them.
132
+ const display = useMemo<Display>(() => {
133
+ const grid = Children.toArray(children).find(
134
+ (c) => isValidElement(c) && typeof c.type === "function" && "isMatrixGrid" in c.type,
135
+ );
136
+ return isValidElement<MatrixGridProps>(grid) ? grid.props.display ?? "both" : "both";
137
+ }, [children]);
138
+
97
139
  const { max, rowTotals, colTotals, grandTotal } = useMemo(
98
140
  () => matrixTotals(rows, cols, value),
99
141
  [rows, cols, value],
100
142
  );
101
143
 
144
+ const heat = useMemo(
145
+ () =>
146
+ matrixHeatScale(
147
+ rows.flatMap((r) => cols.map((c) => value(r.key, c.key))),
148
+ heatScale ?? (display === "heat" ? "linear" : "rank"),
149
+ HEAT_BAND[display],
150
+ ),
151
+ [rows, cols, value, heatScale, display],
152
+ );
153
+
102
154
  const ctx: MatrixContextValue = {
103
155
  rows,
104
156
  cols,
@@ -111,13 +163,26 @@ export function Matrix(props: MatrixProps) {
111
163
  selected,
112
164
  onSelectCell,
113
165
  heatBase: colors[heatColor][600],
166
+ heatAlpha: heat.alpha,
167
+ heatLegend: heat.legend,
114
168
  formatValue,
115
169
  rowLabelWidth,
116
170
  };
117
171
 
172
+ // Bề ngang tối thiểu để lưới còn ĐỌC được, rồi cuộn ngang khi khung hẹp hơn
173
+ // thế. Không có nó, ô co lại theo `flex` cho tới khi cột nhãn — thứ duy nhất
174
+ // nói mỗi hàng là gì — bị bóp còn một chữ cái, và cột Tổng trôi ra ngoài mép:
175
+ // một ma trận 30 cột vẫn "vừa khung" và không còn đọc được gì.
176
+ const minWidth =
177
+ rowLabelWidth +
178
+ cols.length * (MATRIX_CELL_MIN + MATRIX_COL_GAP) +
179
+ (hasTotals ? MATRIX_TOTAL_MIN + MATRIX_COL_GAP : 0);
180
+
118
181
  return (
119
182
  <MatrixContext.Provider value={ctx}>
120
- <View style={styles.container}>{children}</View>
183
+ <ScrollView horizontal contentContainerStyle={styles.scrollContent}>
184
+ <View style={[styles.container, { minWidth }]}>{children}</View>
185
+ </ScrollView>
121
186
  </MatrixContext.Provider>
122
187
  );
123
188
  }
@@ -170,9 +235,15 @@ function MatrixGrid({ display = "both" }: MatrixGridProps) {
170
235
  <View style={styles.grid}>
171
236
  {rows.map((r) => (
172
237
  <View key={r.key} style={styles.row}>
173
- <Text size="sm" numberOfLines={1} style={[styles.rowLabel, { width: rowLabelWidth }]}>
174
- {r.label}
175
- </Text>
238
+ {/* Mark then label, inside the SAME `rowLabelWidth` — the mark eats the
239
+ label's width rather than widening the column, so a marked matrix
240
+ lines up with an unmarked one and the caller's budget still holds. */}
241
+ <View style={[styles.rowLabel, styles.rowLabelCell, { width: rowLabelWidth }]}>
242
+ {r.leading}
243
+ <Text size="sm" numberOfLines={1} style={styles.rowLabelText}>
244
+ {r.label}
245
+ </Text>
246
+ </View>
176
247
  {cols.map((c) => (
177
248
  <MatrixCell key={c.key} ctx={ctx} display={display} row={r} col={c} value={value(r.key, c.key)} />
178
249
  ))}
@@ -196,16 +267,14 @@ interface MatrixCellProps {
196
267
  }
197
268
 
198
269
  function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
199
- const { max, selected, onSelectCell, heatBase, formatValue } = ctx;
270
+ const { selected, onSelectCell, heatBase, heatAlpha, formatValue } = ctx;
200
271
  const showHeat = display === "heat" || display === "both";
201
272
  const showNumber = display === "number" || display === "both";
202
273
 
203
- // A gentler wash behind a number (keeps it legible); the full ramp when the
204
- // colour IS the value (a pure heatmap).
205
274
  const background = showHeat
206
275
  ? value === 0
207
276
  ? colors.zinc[100]
208
- : withAlpha(heatBase, display === "both" ? 0.05 + 0.4 * (value / max) : 0.15 + 0.85 * (value / max))
277
+ : withAlpha(heatBase, heatAlpha(value))
209
278
  : colors.background;
210
279
 
211
280
  const isSelected = selected?.row === row.key && selected?.col === col.key;
@@ -278,15 +347,18 @@ export interface MatrixLegendProps {
278
347
  }
279
348
 
280
349
  function MatrixLegend({ lessLabel, moreLabel }: MatrixLegendProps) {
281
- const { heatBase } = useMatrix();
350
+ const { heatBase, heatLegend } = useMatrix();
282
351
  const locale = useLoticsLocale();
352
+ // Nothing painted, nothing to explain — a less→more ramp over an empty grid
353
+ // asserts a scale no cell is on.
354
+ if (heatLegend.length === 0) return null;
283
355
  return (
284
356
  <View style={styles.scale}>
285
357
  <Text size="xs" color="muted">
286
358
  {lessLabel ?? locale.matrix.less}
287
359
  </Text>
288
- {[0.15, 0.36, 0.57, 0.78, 1].map((alpha) => (
289
- <View key={alpha} style={[styles.swatch, { backgroundColor: withAlpha(heatBase, alpha) }]} />
360
+ {heatLegend.map((alpha, i) => (
361
+ <View key={i} style={[styles.swatch, { backgroundColor: withAlpha(heatBase, alpha) }]} />
290
362
  ))}
291
363
  <Text size="xs" color="muted">
292
364
  {moreLabel ?? locale.matrix.more}
@@ -296,13 +368,26 @@ function MatrixLegend({ lessLabel, moreLabel }: MatrixLegendProps) {
296
368
  }
297
369
 
298
370
  Matrix.Header = MatrixHeader;
299
- Matrix.Grid = MatrixGrid;
371
+ Matrix.Grid = Object.assign(MatrixGrid, { isMatrixGrid: true as const });
300
372
  Matrix.Totals = Object.assign(MatrixTotals, { isMatrixTotals: true as const });
301
373
  Matrix.Legend = MatrixLegend;
302
374
 
303
375
  const styles = StyleSheet.create({
376
+ scrollContent: {
377
+ // Lưới hẹp hơn khung thì vẫn trải hết khung — không có dòng này nó co về bề
378
+ // ngang nội dung và bỏ lại một khoảng trắng bên phải.
379
+ minWidth: "100%",
380
+ },
304
381
  container: {
305
382
  gap: MATRIX_COL_GAP,
383
+ // Và `minWidth: "100%"` một mình KHÔNG đủ: khung cuộn ngang xếp con theo
384
+ // HÀNG, nên cái khung nội dung rộng bằng khung ngoài còn cái lưới bên trong
385
+ // vẫn co về bề ngang nội dung của nó. Chỗ trống ấy không hiện ra thành một
386
+ // khoảng trắng ai cũng thấy — nó hiện ra thành những ô xuống dòng: lưới tự
387
+ // tin mình chỉ đáng bấy nhiêu, ô đứng ở sàn 64, còn một phần ba cái thẻ bỏ
388
+ // không ngay bên cạnh. Cho nó nhận phần dư thì ô rộng ra trước, và cuộn
389
+ // ngang chỉ còn xảy ra khi lưới thật sự dài hơn khung.
390
+ flexGrow: 1,
306
391
  },
307
392
  headRow: {
308
393
  flexDirection: "row",
@@ -320,13 +405,28 @@ const styles = StyleSheet.create({
320
405
  },
321
406
  rowLabel: {
322
407
  paddingRight: 8,
408
+ // Cột nhãn không bao giờ nhường chỗ: nó là thứ duy nhất nói mỗi hàng là gì.
409
+ flexShrink: 0,
410
+ },
411
+ // Chỉ hàng dữ liệu: ô nhãn là một hàng ngang mark + chữ. Dải Tổng và góc tiêu
412
+ // đề không mang mark nên giữ nguyên `rowLabel`.
413
+ rowLabelCell: {
414
+ flexDirection: "row",
415
+ alignItems: "center",
416
+ gap: LEADING_GAP,
417
+ },
418
+ rowLabelText: {
419
+ // Chữ nhận phần còn lại sau cái mark; `minWidth: 0` để nó chịu cắt bớt thay
420
+ // vì đẩy mark ra ngoài mép cột.
421
+ flex: 1,
422
+ minWidth: 0,
323
423
  },
324
424
  cell: {
325
425
  flex: 1,
326
426
  // 52 + hai lần MATRIX_CELL_PAD: cái đệm mới ăn vào bề ngang, nên sàn tối
327
427
  // thiểu phải cộng thêm đúng ngần ấy, không thì ô chật đi và con số dài nhất
328
428
  // là con số đầu tiên bị ép.
329
- minWidth: 52 + MATRIX_CELL_PAD * 2,
429
+ minWidth: MATRIX_CELL_MIN,
330
430
  height: 36,
331
431
  borderRadius: 4,
332
432
  borderWidth: 2,
@@ -372,7 +472,7 @@ const styles = StyleSheet.create({
372
472
  // many columns in a narrow frame never gets a total column narrower than it
373
473
  // used to have.
374
474
  flex: 1,
375
- minWidth: 64,
475
+ minWidth: MATRIX_TOTAL_MIN,
376
476
  paddingLeft: 4,
377
477
  },
378
478
  totalsRow: {
@@ -1,13 +1,24 @@
1
+ import type { ReactNode } from "react";
2
+
1
3
  /**
2
4
  * The data layer for `Matrix` — axis types + the cross-tab aggregation. Kept
3
- * free of any React-Native import so it unit-tests directly (the component in
4
- * `matrix.tsx` renders these numbers; a consumer can drive a KPI off the same
5
- * `matrixTotals` it shows in the grid).
5
+ * free of any React-Native import (the axis mark is a TYPE-only `ReactNode`, so
6
+ * nothing here runs) and it unit-tests directly: the component in `matrix.tsx`
7
+ * renders these numbers, and a consumer can drive a KPI off the same
8
+ * `matrixTotals` it shows in the grid.
6
9
  */
7
10
 
8
11
  export interface MatrixAxisItem {
9
12
  key: string;
10
13
  label: string;
14
+ /**
15
+ * An identity mark before the label — an `Avatar`, a status dot. ROWS ONLY:
16
+ * a row is a subject and a mark is what makes it recognizable at a glance,
17
+ * while a column is a period or a category and has no identity to carry, so
18
+ * `Matrix.Header` ignores this. The mark is drawn INSIDE `rowLabelWidth`, so
19
+ * it eats the label's width rather than widening the column.
20
+ */
21
+ leading?: ReactNode;
11
22
  }
12
23
 
13
24
  /** The inspected cell — host renders its detail (a filtered list) underneath. */
@@ -24,6 +35,63 @@ export interface MatrixTotalsResult {
24
35
  max: number;
25
36
  }
26
37
 
38
+ /** How a cell's value becomes ink. See `matrixHeatScale`. */
39
+ export type MatrixHeatScale = "linear" | "rank";
40
+
41
+ export interface MatrixHeatScaleResult {
42
+ /** The alpha this scale paints a cell with. Zero (and below) paints nothing. */
43
+ alpha: (value: number) => number;
44
+ /** The legend's swatches — the SAME alphas, sampled across the painted set. */
45
+ legend: number[];
46
+ }
47
+
48
+ /**
49
+ * Value → ink, and the legend that explains it, from ONE expression.
50
+ *
51
+ * `linear` maps a cell onto the band by its share of the largest cell. That is
52
+ * honest only when the values are spread; on the long tail every real dataset
53
+ * has (one big month, eleven ordinary ones) it spends the whole band on the
54
+ * outlier and leaves everything else inside a step the eye cannot resolve.
55
+ * `rank` maps a cell by its POSITION among the DISTINCT values painted, so the
56
+ * band is spent on the differences the reader can see, the smallest cell always
57
+ * lands on `lo` and the largest on `hi`, and equal cells keep equal ink.
58
+ *
59
+ * The legend is SAMPLED from that same function rather than restated, so it can
60
+ * never paint a shade the grid cannot reach: a legend written as its own list of
61
+ * alphas is a second copy of the scale, and it drifts the moment the band moves.
62
+ */
63
+ export function matrixHeatScale(
64
+ values: number[],
65
+ scale: MatrixHeatScale,
66
+ band: [number, number],
67
+ ): MatrixHeatScaleResult {
68
+ const [lo, hi] = band;
69
+ const distinct = [...new Set(values.filter((v) => v > 0))].sort((a, b) => a - b);
70
+ const top = distinct[distinct.length - 1] ?? 1;
71
+ const at = (t: number) => lo + (hi - lo) * t;
72
+
73
+ // One pass to rank: a per-cell scan would be quadratic on a wide sheet.
74
+ const rank = new Map<number, number>();
75
+ distinct.forEach((v, i) => rank.set(v, i));
76
+
77
+ const alpha = (v: number): number => {
78
+ if (v <= 0) return 0;
79
+ // A single distinct value has no order to spend a band on — every cell is
80
+ // the largest one, and the top of the band is what says so.
81
+ if (distinct.length < 2) return hi;
82
+ if (scale === "linear") return at(v / top);
83
+ return at((rank.get(v) ?? distinct.length - 1) / (distinct.length - 1));
84
+ };
85
+
86
+ return {
87
+ alpha,
88
+ legend:
89
+ distinct.length === 0
90
+ ? []
91
+ : [0, 0.25, 0.5, 0.75, 1].map((q) => alpha(distinct[Math.round(q * (distinct.length - 1))])),
92
+ };
93
+ }
94
+
27
95
  /**
28
96
  * Row sums, column sums, grand total, and the max single cell — computed in one
29
97
  * pass. The single source of truth for the matrix's totals.
@@ -0,0 +1,16 @@
1
+ // Month arithmetic on `yyyy-MM-dd` strings, free of React and of the clock.
2
+ // A month is keyed by its first day so a value round-trips through a date
3
+ // field unchanged.
4
+
5
+ /** The first day of the month `iso` falls in. */
6
+ export function monthStart(iso: string): string {
7
+ return `${iso.slice(0, 7)}-01`;
8
+ }
9
+
10
+ /** `delta` months from `iso`, as that month's first day. */
11
+ export function shiftMonth(iso: string, delta: number): string {
12
+ const y = Number(iso.slice(0, 4));
13
+ const m = Number(iso.slice(5, 7)) - 1 + delta;
14
+ const d = new Date(y, m, 1);
15
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;
16
+ }
@@ -0,0 +1,62 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { formatDate } from "./format_date";
3
+ import { IconButton } from "./icon_button";
4
+ import { useLoticsLocale } from "./locale";
5
+ import { monthStart, shiftMonth } from "./month_math";
6
+ import { Text } from "./text";
7
+
8
+ export interface MonthStepperLabels {
9
+ previous: string;
10
+ next: string;
11
+ }
12
+
13
+ export interface MonthStepperProps {
14
+ /** The month, as any day in it (`yyyy-MM-dd`); `onChange` always hands back its first day. */
15
+ value: string;
16
+ onChange: (next: string) => void;
17
+ /** The last month that may be stepped INTO — this month, for a surface about what has happened. */
18
+ max?: string;
19
+ min?: string;
20
+ labels?: Partial<MonthStepperLabels>;
21
+ /** BCP-47 for the month name. Defaults to the active locale. */
22
+ locale?: string;
23
+ testID?: string;
24
+ }
25
+
26
+ /**
27
+ * ‹ Tháng 9 2026 › — the period control for a surface keyed by a MONTH: a
28
+ * plan, a roster, a month's attendance. `DateRangeFilterField` is for a
29
+ * surface that takes any range; a month-keyed record cannot take "the 3rd to
30
+ * the 19th", so the control offers only the moves that exist.
31
+ *
32
+ * The bound is the contract. A stepper that walks into next year reads a plan
33
+ * for a month nobody has given one for, and two hand-rolled steppers in one
34
+ * app disagreed on exactly this — one refused the future, one did not.
35
+ */
36
+ export function MonthStepper(props: MonthStepperProps) {
37
+ const { value, onChange, max, min, labels, locale, testID } = props;
38
+ const pack = useLoticsLocale();
39
+ const l = { ...pack.monthStepper, ...labels };
40
+ const month = monthStart(value);
41
+ const prev = shiftMonth(month, -1);
42
+ const next = shiftMonth(month, 1);
43
+ const atMin = min !== undefined && prev < monthStart(min);
44
+ const atMax = max !== undefined && next > monthStart(max);
45
+
46
+ return (
47
+ <View style={styles.row} testID={testID}>
48
+ <IconButton icon="chevron-left" size="sm" accessibilityLabel={l.previous} disabled={atMin} onPress={() => onChange(prev)} />
49
+ <Text size="sm" weight="medium" tabular align="center" numberOfLines={1} style={styles.label}>
50
+ {formatDate(`${month}T00:00:00`, { format: "monthYear", locale: locale ?? pack.bcp47 })}
51
+ </Text>
52
+ <IconButton icon="chevron-right" size="sm" accessibilityLabel={l.next} disabled={atMax} onPress={() => onChange(next)} />
53
+ </View>
54
+ );
55
+ }
56
+
57
+ const styles = StyleSheet.create({
58
+ row: { flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start" },
59
+ // Wide enough for the longest month name in either pack, so the arrows do not
60
+ // shuffle sideways as the reader steps.
61
+ label: { minWidth: 148 },
62
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * An organization's name without the legal form it opens or closes with.
3
+ *
4
+ * A register of companies marks the same letters on every row, because the
5
+ * name opens with "Công ty TNHH" or closes with "Ltd." — the identity mark is
6
+ * derived from the first letters, and the first 60px of the name column are
7
+ * spent on words that tell no two rows apart. composition.md § Identity marks
8
+ * says to strip the form from BOTH ends before the name reaches the mark, and
9
+ * to show the stripped name as the row's title; this is that function, so two
10
+ * screens cannot disagree about what counts as a form.
11
+ *
12
+ * The forms are the ones the kit's two locales meet. A name that IS only a
13
+ * form comes back unchanged — an empty title is worse than a generic one.
14
+ */
15
+ const LEADING = [
16
+ "công ty cổ phần",
17
+ "công ty cp",
18
+ "cty cp",
19
+ "công ty tnhh một thành viên",
20
+ "công ty tnhh mtv",
21
+ "công ty tnhh",
22
+ "cty tnhh",
23
+ "tổng công ty",
24
+ "công ty",
25
+ "cty",
26
+ "hợp tác xã",
27
+ "htx",
28
+ "hộ kinh doanh",
29
+ "hkd",
30
+ "doanh nghiệp tư nhân",
31
+ "dntn",
32
+ "tập đoàn",
33
+ "chi nhánh",
34
+ "the",
35
+ ];
36
+
37
+ const TRAILING = [
38
+ "co., ltd.",
39
+ "co., ltd",
40
+ "co. ltd",
41
+ "co ltd",
42
+ "pte. ltd.",
43
+ "pte ltd",
44
+ "ltd.",
45
+ "ltd",
46
+ "limited",
47
+ "llc",
48
+ "inc.",
49
+ "inc",
50
+ "corp.",
51
+ "corp",
52
+ "corporation",
53
+ "company",
54
+ "jsc",
55
+ "plc",
56
+ "gmbh",
57
+ "s.a.",
58
+ "cổ phần",
59
+ "tnhh",
60
+ "cp",
61
+ ];
62
+
63
+ /** Longest first, so "công ty tnhh mtv" is tried before "công ty tnhh" before "công ty". */
64
+ const byLength = (a: string, b: string) => b.length - a.length;
65
+ const LEAD = [...LEADING].sort(byLength);
66
+ const TRAIL = [...TRAILING].sort(byLength);
67
+
68
+ const SEP = /^[\s,.\-–—]+|[\s,.\-–—]+$/g;
69
+
70
+ function stripOnce(name: string): string {
71
+ const lower = name.toLowerCase();
72
+ for (const form of LEAD) {
73
+ if (lower.startsWith(form) && (lower.length === form.length || /[\s,.]/.test(lower.charAt(form.length)))) {
74
+ return name.slice(form.length).replace(SEP, "");
75
+ }
76
+ }
77
+ for (const form of TRAIL) {
78
+ const at = lower.length - form.length;
79
+ if (at > 0 && lower.endsWith(form) && /[\s,.]/.test(lower.charAt(at - 1))) {
80
+ return name.slice(0, at).replace(SEP, "");
81
+ }
82
+ }
83
+ return name;
84
+ }
85
+
86
+ export function stripLegalForm(name: string): string {
87
+ let cur = name.trim();
88
+ for (;;) {
89
+ const next = stripOnce(cur);
90
+ if (next === cur) break;
91
+ cur = next;
92
+ }
93
+ return cur === "" ? name.trim() : cur;
94
+ }
@@ -16,3 +16,22 @@ export function normalizeForSearch(s: string): string {
16
16
  .replace(/\p{Diacritic}/gu, "")
17
17
  .replaceAll(String.fromCharCode(0x111), "d");
18
18
  }
19
+
20
+ /**
21
+ * A CODE the way a person types it — a plate, a container number, a voucher
22
+ * number. `14B-031.02` is punctuated three different ways across one yard's
23
+ * paperwork and nobody typing fast reproduces any of them, so both sides drop
24
+ * everything that is not a letter or a digit before matching: `14b031` finds
25
+ * it, and so does `031.02`. Still a substring test, never a subsequence one, or
26
+ * `1402` would match half the register.
27
+ */
28
+ export function codeKey(s: string): string {
29
+ return normalizeForSearch(s).replace(/[^a-z0-9]/g, "");
30
+ }
31
+
32
+ /** Does any of these fields answer the query, as a code? An empty query matches everything. */
33
+ export function codeMatches(query: string, ...fields: string[]): boolean {
34
+ const q = codeKey(query);
35
+ if (q === "") return true;
36
+ return fields.some((f) => codeKey(f).includes(q));
37
+ }