@data-profile/fingerprint 0.0.0-stage → 0.3.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/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0](https://github.com/SirKettle/data-profile/compare/fingerprint-v0.2.0...fingerprint-v0.3.0) (2026-10-26)
4
+
5
+
6
+ ### Features
7
+
8
+ * add @data-profile/core, and build the fingerprint on it ([30873f9](https://github.com/SirKettle/data-profile/commit/30873f9997b51fde6eb96cd233a2c486d87d8f04))
9
+
10
+
11
+ ### Dependencies
12
+
13
+ * The following workspace dependencies were updated
14
+ * dependencies
15
+ * @data-profile/core bumped from ^0.0.0 to ^0.1.0
16
+
17
+ ## [0.2.0](https://github.com/SirKettle/data-profile/compare/fingerprint-v0.1.0...fingerprint-v0.2.0) (2026-10-26)
18
+
19
+
20
+ ### ⚠ BREAKING CHANGES
21
+
22
+ * the package is now @data-profile/fingerprint. Install it in place of @thirkettle/collection-fingerprint and change imports from "@thirkettle/collection-fingerprint" (and "/react", "/styles.css") to "@data-profile/fingerprint" (and "/react", "/styles.css"). Nothing else changes.
23
+
24
+ ### Features
25
+
26
+ * publish as @data-profile/fingerprint, from a Bun workspace ([6166a06](https://github.com/SirKettle/data-profile/commit/6166a0689a7761fb68ab3492eba2c57acc0a67f2))
27
+
28
+ ## 0.1.0 (2026-10-22)
29
+
30
+
31
+ ### Features
32
+
33
+ * import @thirkettle/collection-fingerprint ([c214fbe](https://github.com/SirKettle/collection-fingerprint/commit/c214fbedebc0c8c6faf421272f50d1d2be9cf2ec))
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Will Thirkettle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,346 @@
1
- # Temporary Holding Version
1
+ # @data-profile/fingerprint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Draw any collection as a fingerprint: one mark per item, laid along the ridges of a real-looking
4
+ fingerprint, grouped and coloured by whatever you know about the items. A library by genre, a
5
+ record collection by decade, a garden by plant family. The print's form comes from the data
6
+ itself, so every collection gets its own, and it stays the same each time you draw it.
7
+
8
+ It is not device or browser fingerprinting: nothing here identifies anyone.
9
+
10
+ **[Documentation and live demos](https://sirkettle.github.io/data-profile/)**
11
+
12
+ - **Deterministic.** The same items always draw the same print, in any order, at any size.
13
+ - **Honest.** Mark area is the item's weight, unknown values are drawn rather than dropped, and when
14
+ there are more items than marks, the print says how many items each mark stands for.
15
+ - **Several things at once.** Colour, shape and lightness can each show a different property.
16
+ - **Your own marks.** Simple shapes by default; a renderer can draw anything in SVG.
17
+ - **Small.** One dependency, [`@data-profile/core`](../core), which every data-profile view shares.
18
+ The layout works without React; the components need React 18 or 19.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ npm install @data-profile/fingerprint
24
+ ```
25
+
26
+ ## A first fingerprint
27
+
28
+ <!-- example: first-fingerprint.tsx -->
29
+
30
+ ```tsx
31
+ import type { FingerprintDimension, FingerprintItem } from "@data-profile/fingerprint";
32
+ import {
33
+ FingerprintCanvas,
34
+ FingerprintLegend,
35
+ useFingerprint,
36
+ } from "@data-profile/fingerprint/react";
37
+ import "@data-profile/fingerprint/styles.css";
38
+
39
+ const dimensions: FingerprintDimension[] = [
40
+ {
41
+ id: "genre",
42
+ label: "Genre",
43
+ categories: [
44
+ { id: "fiction", label: "Fiction" },
45
+ { id: "poetry", label: "Poetry" },
46
+ { id: "history", label: "History" },
47
+ ],
48
+ },
49
+ ];
50
+
51
+ const items: FingerprintItem[] = [
52
+ { id: "b1", values: { genre: "fiction" } },
53
+ { id: "b2", values: { genre: "poetry" } },
54
+ { id: "b3", values: { genre: "fiction" } },
55
+ { id: "b4", values: { genre: null } }, // unknown: drawn, in its own category
56
+ ];
57
+
58
+ export function Library() {
59
+ const fp = useFingerprint({ items, dimensions, unit: "books" });
60
+ return (
61
+ <div style={{ maxWidth: 420 }}>
62
+ <FingerprintCanvas {...fp.canvasProps} title="My library" />
63
+ {fp.layout.legends.map((group) => (
64
+ <FingerprintLegend key={group.dimensionId} {...fp.legendProps(group)} />
65
+ ))}
66
+ </div>
67
+ );
68
+ }
69
+ ```
70
+
71
+ The canvas is an SVG that fills its container's width. The stylesheet is optional: it styles the
72
+ legend, list and view switch, and everything works without it.
73
+
74
+ `fp.canvasProps` carries the layout, its description, the selection and the unit, and
75
+ `fp.legendProps(group)` the legend group and the selection, so clicking a legend entry or a mark
76
+ selects a category everywhere at once.
77
+
78
+ ## Items and dimensions
79
+
80
+ An **item** is one thing in the collection: a unique, stable `id`, an optional `weight` (mark area
81
+ is proportional to it; the default is 1), and its category in each dimension, by id, in `values`.
82
+
83
+ A **dimension** is one way of grouping the items: an `id`, a `label` and its `categories` in the
84
+ order you want them drawn. A category can carry its own `color` and an `href`. Mark a dimension
85
+ `ordered: true` when its categories run in a meaningful order, such as decades or sizes.
86
+
87
+ A value that is `null`, `undefined`, missing, or not one of the dimension's categories counts as
88
+ **unknown**. Unknown items are drawn, last, in their own category (labelled by `unknownLabel`,
89
+ default "Unknown").
90
+
91
+ **Treat the items array as immutable.** The layout caches work against the array you pass, so a
92
+ changed collection should be a new array, as React state usually is.
93
+
94
+ ## Encodings: what each property shows
95
+
96
+ One dimension **arranges** the marks: it sets the order along the ridges, so its categories form
97
+ runs. **Colour**, **shape** and **lightness** can each show a dimension too:
98
+
99
+ <!-- example: encoding.ts -->
100
+
101
+ ```ts
102
+ import type { FingerprintEncoding } from "@data-profile/fingerprint";
103
+
104
+ export const encoding: FingerprintEncoding = {
105
+ arrange: "genre",
106
+ color: "genre",
107
+ shape: "state",
108
+ lightness: "decade",
109
+ };
110
+ ```
111
+
112
+ - Leave `color` and `shape` out and they follow `arrange`; set one to `null` to turn it off.
113
+ - **Lightness needs an ordered dimension**: the first category is faintest. On unordered categories
114
+ fainter and stronger would read as less and more, so that is an error.
115
+ - Colour stays distinguishable to about 10 categories and the default shapes to 5; beyond those,
116
+ `layout.warnings` says so and only the legend tells them apart.
117
+ - Size is never an encoding: mark area is reserved for weight.
118
+
119
+ ### Views
120
+
121
+ A **view** is a named encoding. Pass several to `useFingerprint` and switch between them with
122
+ `FingerprintViewSwitch`. Marks glide to their new places, and the ridges stay put.
123
+
124
+ <!-- example: views.tsx -->
125
+
126
+ ```tsx
127
+ import type { FingerprintDimension, FingerprintItem } from "@data-profile/fingerprint";
128
+ import {
129
+ FingerprintCanvas,
130
+ FingerprintViewSwitch,
131
+ useFingerprint,
132
+ type FingerprintView,
133
+ } from "@data-profile/fingerprint/react";
134
+
135
+ const views: FingerprintView[] = [
136
+ {
137
+ id: "all",
138
+ label: "Combined",
139
+ encoding: { arrange: "genre", color: "genre", shape: "state", lightness: "decade" },
140
+ },
141
+ { id: "genre", label: "Genre", encoding: { arrange: "genre" } },
142
+ { id: "decade", label: "Decade", encoding: { arrange: "decade" } },
143
+ ];
144
+
145
+ export function Shelves({
146
+ items,
147
+ dimensions,
148
+ }: {
149
+ items: FingerprintItem[];
150
+ dimensions: FingerprintDimension[];
151
+ }) {
152
+ const fp = useFingerprint({ items, dimensions, views, unit: "books" });
153
+ return (
154
+ <>
155
+ <FingerprintViewSwitch views={fp.views} value={fp.viewId} onChange={fp.setViewId} />
156
+ <FingerprintCanvas {...fp.canvasProps} title="My library" />
157
+ </>
158
+ );
159
+ }
160
+ ```
161
+
162
+ With no `views`, there is one per dimension.
163
+
164
+ ## The print's form
165
+
166
+ Fingerprints come in three patterns: **loop**, **whorl** and **arch**. `formFromItems(items,
167
+ dimensions)`, which `useFingerprint` uses, picks one from the data (an arch under 12 items, a loop
168
+ when one category of the first dimension holds 40% or more, otherwise a whorl) and places the core
169
+ from where the second and third dimensions' values sit. It changes in coarse steps, so a collection
170
+ keeps its print as it grows by an item or two. Pass `form` to override any part of it.
171
+
172
+ ("Form" is the print's geometry. A mark's _shape_ is an encoding, above.)
173
+
174
+ ## Selection, legends and lists
175
+
176
+ A selection is a category _of a dimension_, so choosing "Unread" in a shape legend highlights unread
177
+ items whatever their colour. Choose a category in a legend, a list or by clicking a mark; choose it
178
+ again to clear. The canvas dims everything else, and announces the choice to screen readers.
179
+
180
+ `FingerprintList` is one dimension as a table: every category with its count and share. Render one
181
+ per legend group with `{...fp.listProps(group)}`; together they are the accessible equivalent of the
182
+ drawing.
183
+
184
+ ## Callouts
185
+
186
+ `annotations` pins short notes to the print. You write the words; the canvas points a leader at a
187
+ real mark of the categories you name:
188
+
189
+ <!-- example: annotations.ts -->
190
+
191
+ ```ts
192
+ import type { FingerprintAnnotation } from "@data-profile/fingerprint";
193
+
194
+ export const annotations: FingerprintAnnotation[] = [
195
+ {
196
+ id: "lead",
197
+ dimensionId: "genre",
198
+ categoryIds: ["fiction"],
199
+ title: "Mostly fiction",
200
+ body: "62% of the library.",
201
+ },
202
+ ];
203
+ ```
204
+
205
+ Callouts sit beside the print when its container is at least `callouts.breakpoint` wide (640 px by
206
+ default) and are numbered beneath it otherwise; `callouts.placement` can force either.
207
+
208
+ ## Renderers
209
+
210
+ A renderer draws one mark, centred on the origin, `node.size` across, with the ridge running along
211
+ the x axis; the canvas positions and rotates it. Each mark arrives with its properties resolved:
212
+
213
+ | `node.` | Holds |
214
+ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
215
+ | `color` | The colour dimension's colour for it, or `currentColor` when nothing drives colour |
216
+ | `shape` | The index of its category in the shape dimension (0, 1, 2, …); `0` when nothing drives shape; `null` when its shape category is unknown |
217
+ | `lightness` | 0 (faintest) to 1 along the lightness dimension, or `null`. `markOpacity(node)` turns it into an opacity |
218
+ | `categories` | Its category in every encoded dimension, by dimension id |
219
+ | `size`, `weight`, `itemIds` | Its footprint, summed weight and the items it stands for |
220
+
221
+ <!-- example: renderer.tsx -->
222
+
223
+ ```tsx
224
+ import { markOpacity } from "@data-profile/fingerprint";
225
+ import type { FingerprintRenderer } from "@data-profile/fingerprint/react";
226
+
227
+ const glyphs = ["★", "●", "■", "▲", "◆"];
228
+
229
+ export const stars: FingerprintRenderer = {
230
+ name: "stars",
231
+ mark: ({ node }) => (
232
+ <text
233
+ fontSize={node.size}
234
+ textAnchor="middle"
235
+ dy="0.35em"
236
+ fill={node.color}
237
+ opacity={markOpacity(node)}
238
+ >
239
+ {node.shape === null ? "?" : glyphs[node.shape % glyphs.length]}
240
+ </text>
241
+ ),
242
+ };
243
+ ```
244
+
245
+ `shapesRenderer` (the default, five shapes) and `dotsRenderer` come with the package. A renderer can
246
+ also return SVG `defs` once per drawing. Marks are memoised, so a renderer must depend on its props
247
+ alone.
248
+
249
+ ## Large collections and motion
250
+
251
+ At most about `maxMarks` marks are drawn (600 by default). Above that, neighbouring items that
252
+ agree on every encoded dimension share a mark, `layout.itemsPerMark` says how many, and the
253
+ description says so too. Nothing is sampled away.
254
+
255
+ Marks glide between views while there are at most `motion.glide` of them (800) and recolour gently
256
+ up to `motion.fade` (400); above those they change at once, because SVG cannot animate more marks
257
+ smoothly. Under `prefers-reduced-motion` nothing animates.
258
+
259
+ Ridges are drawn faintly behind small collections, where the marks alone would not read as a
260
+ fingerprint; `ridges={{ show: "always" }}` or `"never"` overrides that.
261
+
262
+ ## Without React
263
+
264
+ <!-- example: headless.ts -->
265
+
266
+ ```ts
267
+ import {
268
+ describeLayout,
269
+ formFromItems,
270
+ layoutFingerprint,
271
+ type FingerprintDimension,
272
+ type FingerprintItem,
273
+ } from "@data-profile/fingerprint";
274
+
275
+ export function summarise(items: FingerprintItem[], dimensions: FingerprintDimension[]) {
276
+ const layout = layoutFingerprint({
277
+ items,
278
+ dimensions,
279
+ encoding: { arrange: "genre" },
280
+ form: formFromItems(items, dimensions),
281
+ maxMarks: 600,
282
+ });
283
+ return {
284
+ description: describeLayout(layout, "books"),
285
+ pattern: layout.form.pattern,
286
+ marks: layout.nodes.length,
287
+ warnings: layout.warnings,
288
+ };
289
+ }
290
+ ```
291
+
292
+ `layout.nodes` are the positioned marks, `layout.legends` one legend per encoded dimension,
293
+ `layout.ridges` the ridge polylines, all in a fixed `layout.width` × `layout.height` frame.
294
+ `layoutFingerprint` throws `FingerprintDataError` on data it cannot draw truthfully (duplicate ids,
295
+ lightness on an unordered dimension, …); `useFingerprint` returns it as `fp.error` instead.
296
+
297
+ ## Reference
298
+
299
+ ### `useFingerprint(options)`
300
+
301
+ | Option | | Default |
302
+ | --------------------- | ----------------------------------- | ----------------- |
303
+ | `items`, `dimensions` | The data | required |
304
+ | `views` | Named encodings, in switcher order | one per dimension |
305
+ | `initialViewId` | The view shown first | the first |
306
+ | `form` | Overrides parts of the derived form | from the data |
307
+ | `seed` | Varies the form's free parts | `"fingerprint"` |
308
+ | `maxMarks` | The mark cap | 600 |
309
+ | `unit` | What one item is ("books") | `"items"` |
310
+
311
+ Returns `layout`, `description`, `error`, `views`, `view`, `viewId`, `setViewId`, `selected`,
312
+ `setSelected`, and the bundles `canvasProps`, `legendProps(group)` and `listProps(group)`.
313
+
314
+ ### `<FingerprintCanvas>`
315
+
316
+ | Prop | | Default |
317
+ | ------------------------------------------------------- | -------------------------------------------------------------------- | ---------------- |
318
+ | `layout`, `description`, `selected`, `onSelect`, `unit` | From `fp.canvasProps` | |
319
+ | `title` | The drawing's accessible name | required |
320
+ | `renderer` | Draws each mark | `shapesRenderer` |
321
+ | `annotations` | Callouts | none |
322
+ | `callouts` | `{ placement?: "auto" \| "beside" \| "below", breakpoint?: number }` | `auto`, 640 |
323
+ | `motion` | `{ glide?: number, fade?: number }` | 800, 400 |
324
+ | `ridges` | `{ show?, fadeFrom?, fadeTo?, opacity?, color?, width? }` | `show: "auto"` |
325
+ | `className` | Extra class | |
326
+
327
+ ### `<FingerprintLegend>` and `<FingerprintList>`
328
+
329
+ Both take `group`, `selected` and `onSelect` (from `fp.legendProps(group)` or `fp.listProps(group)`)
330
+ and a `className`. The legend also takes `renderer` (match the canvas), `heading`, `counts` and
331
+ `hideEmpty`; the list takes `unit` and `linkLabel`.
332
+
333
+ ### `<FingerprintViewSwitch>`
334
+
335
+ `views`, `value` (the active id), `onChange`, and an accessible `label` (default "View").
336
+
337
+ ## Styling
338
+
339
+ The components are unstyled apart from what layout needs, and every part has an `fp-` class:
340
+ `fp-canvas`, `fp-mark`, `fp-legend`, `fp-legend__toggle`, `fp-list`, `fp-views__view` and others.
341
+ The optional `@data-profile/fingerprint/styles.css` styles them neutrally; theme it with
342
+ `--fp-accent`, `--fp-accent-text`, `--fp-muted`, `--fp-border` and `--fp-radius`.
343
+
344
+ ## Licence
345
+
346
+ MIT