mapped-image 0.1.2 → 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/README.md CHANGED
@@ -5,12 +5,12 @@ A React component that overlays a clickable row/column grid on top of one or mor
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install github:notetuwu/mapped-image
8
+ npm install mapped-image
9
9
  ```
10
10
 
11
11
  `leaflet` and `react-leaflet` are declared as dependencies and installed automatically. `react` and `react-dom` are peer dependencies, resolved from your app.
12
12
 
13
- > **Note:** this package currently ships untranspiled `.tsx` source (no build step) and is intended to be installed straight from this repository's source. Your app's own bundler is expected to compile it. If you instead need a pre-built `dist` (ESM/CJS + type declarations) for non-TS-aware consumers, that's not set up yet.
13
+ The package ships compiled ESM + type declarations (built with `tsup`), so it works like any normal npm package no extra bundler configuration needed.
14
14
 
15
15
  ## Usage
16
16
 
@@ -46,6 +46,10 @@ Clicking a cell toggles its selection (filled red) and reports the cell's row nu
46
46
  | `onSelectedImageIndexChange` | `(index: number) => void` | Fires whenever the active image changes. |
47
47
  | `renderImageSelector` | `(props: ImageSelectorProps) => ReactNode` | Optional override for the image-switcher UI rendered over the map. Defaults to a row of buttons; pass your own to fully customize it, or build your own selector entirely outside this component using the controlled `selectedImageIndex`/`onSelectedImageIndexChange` props instead. |
48
48
  | `maxBoundsPadding` | `number` | Extra pannable margin, in pixels, beyond the image edges. Defaults to `50`. |
49
+ | `zoomMultiplier` | `number` | Scales the default fit-to-container zoom. `<1` zooms out, `>1` zooms in. Defaults to `1`. |
50
+ | `weightsEditable` | `boolean` | When `true`, draggable handles appear on every internal row/column boundary, letting the user resize relative weights with the mouse. Defaults to `false`. |
51
+ | `onColumnWeightsChange` | `(weights: number[]) => void` | Fires once a column-weight drag is released, with the full resolved weight array for the currently displayed image. |
52
+ | `onRowWeightsChange` | `(weights: number[]) => void` | Same as `onColumnWeightsChange`, for row-weight drags. |
49
53
 
50
54
  ### `ImageConfig`
51
55
 
@@ -59,10 +63,11 @@ Clicking a cell toggles its selection (filled red) and reports the cell's row nu
59
63
  | `rowWeights` | `Record<number, number>` | Same as `columnWeights`, but for row heights. |
60
64
 
61
65
  > Both `rows`/`columns` must be greater than `0`.
66
+ > `columnWeights`/`rowWeights` indices follow the array order used internally (column index `0` = leftmost; row index `0` = the row nearest the *bottom* of the image, not the top, since that's the underlying coordinate system's origin) — keep this in mind if you see row weights affecting the opposite row from what you expected.
62
67
 
63
68
  ## Building an editor
64
69
 
65
- This package intentionally exposes `selectedImageIndex`/`onSelectedImageIndexChange` and a controllable `selectedCells`/`onSelectedCellsChange` so a separate editor app (outside this package) can drive the grid entirely from its own state — e.g. to add/remove rows and columns, adjust `columnWeights`/`rowWeights`, and persist the resulting `ImageConfig[]` plus selection as a JSON config to reload later. There's no editor UI in this package itself.
70
+ This package intentionally exposes `selectedImageIndex`/`onSelectedImageIndexChange` and a controllable `selectedCells`/`onSelectedCellsChange` so a separate editor app (outside this package) can drive the grid entirely from its own state — e.g. to add/remove rows and columns, persist the resulting `ImageConfig[]` plus selection as a JSON config to reload later, and build its own row/column-count controls. For weight editing specifically, set `weightsEditable` and read the result from `onColumnWeightsChange`/`onRowWeightsChange` to update your own copy of `ImageConfig.columnWeights`/`rowWeights` (drags are previewed locally for responsiveness and only reported once released). There's no other editor UI in this package itself.
66
71
 
67
72
  ## Development
68
73
 
@@ -1,7 +1,8 @@
1
- import type { LatLngTuple } from "leaflet";
2
- import type { ReactNode } from "react";
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { LatLngTuple } from 'leaflet';
3
4
 
4
- export type ImageConfig = {
5
+ type ImageConfig = {
5
6
  name: string;
6
7
  src: string;
7
8
  columns: number;
@@ -11,14 +12,12 @@ export type ImageConfig = {
11
12
  /** rowIndex -> relative height weight, default weight is 1. Only needs entries for non-uniform rows. */
12
13
  rowWeights?: Record<number, number>;
13
14
  };
14
-
15
- export type ImageSelectorProps = {
15
+ type ImageSelectorProps = {
16
16
  images: ImageConfig[];
17
17
  selectedIndex: number;
18
18
  onSelect: (index: number) => void;
19
19
  };
20
-
21
- export type MappedImageProps = {
20
+ type MappedImageProps = {
22
21
  images: ImageConfig[];
23
22
  alt: string;
24
23
  width: number;
@@ -31,9 +30,16 @@ export type MappedImageProps = {
31
30
  renderImageSelector?: (props: ImageSelectorProps) => ReactNode;
32
31
  /** Extra pannable margin (in pixels) beyond the image edges. Defaults to 50. */
33
32
  maxBoundsPadding?: number;
33
+ /** Scales the default fit-to-container zoom. <1 zooms out, >1 zooms in. Defaults to 1. */
34
+ zoomMultiplier?: number;
35
+ /** When true, draggable handles appear between rows/columns to resize their relative weights via mouse. Defaults to false. */
36
+ weightsEditable?: boolean;
37
+ /** Fires once a column-weight drag is released, with the full resolved weight array for the current image. */
38
+ onColumnWeightsChange?: (weights: number[]) => void;
39
+ /** Fires once a row-weight drag is released, with the full resolved weight array for the current image. */
40
+ onRowWeightsChange?: (weights: number[]) => void;
34
41
  };
35
-
36
- export type ImageLayerProps = {
42
+ type ImageLayerProps = {
37
43
  image: ImageConfig;
38
44
  colOffset: number;
39
45
  rowOffset: number;
@@ -42,10 +48,13 @@ export type ImageLayerProps = {
42
48
  alt: string;
43
49
  selectedCells: Set<string>;
44
50
  maxBoundsPadding?: number;
51
+ zoomMultiplier?: number;
52
+ weightsEditable?: boolean;
53
+ onColumnWeightsChange?: (weights: number[]) => void;
54
+ onRowWeightsChange?: (weights: number[]) => void;
45
55
  onCellClick: GridProps["onCellClick"];
46
56
  };
47
-
48
- export type GridProps = {
57
+ type GridProps = {
49
58
  rows: number;
50
59
  rowOffset: number;
51
60
  rowWeights: number[];
@@ -53,10 +62,16 @@ export type GridProps = {
53
62
  columnWeights: number[];
54
63
  imgBounds: LatLngTuple;
55
64
  selectedCells: Set<string>;
65
+ weightsEditable?: boolean;
66
+ onColumnWeightsChange?: (weights: number[]) => void;
67
+ onRowWeightsChange?: (weights: number[]) => void;
56
68
  onCellClick?: (props: ICellClickProps) => void;
57
69
  };
58
-
59
- export interface ICellClickProps {
70
+ interface ICellClickProps {
60
71
  col: string;
61
72
  row: number;
62
73
  }
74
+
75
+ declare const MappedImage: (props: MappedImageProps) => react.JSX.Element;
76
+
77
+ export { type GridProps, type ICellClickProps, type ImageConfig, type ImageLayerProps, type ImageSelectorProps, MappedImage, type MappedImageProps };
@@ -0,0 +1,452 @@
1
+ // MappedImage.tsx
2
+ import { useState as useState3 } from "react";
3
+ import "leaflet/dist/leaflet.css";
4
+
5
+ // #style-inject:#style-inject
6
+ function styleInject(css, { insertAt } = {}) {
7
+ if (!css || typeof document === "undefined") return;
8
+ const head = document.head || document.getElementsByTagName("head")[0];
9
+ const style = document.createElement("style");
10
+ style.type = "text/css";
11
+ if (insertAt === "top") {
12
+ if (head.firstChild) {
13
+ head.insertBefore(style, head.firstChild);
14
+ } else {
15
+ head.appendChild(style);
16
+ }
17
+ } else {
18
+ head.appendChild(style);
19
+ }
20
+ if (style.styleSheet) {
21
+ style.styleSheet.cssText = css;
22
+ } else {
23
+ style.appendChild(document.createTextNode(css));
24
+ }
25
+ }
26
+
27
+ // MappedImage.css
28
+ styleInject(".grid-header-label-icon {\n background: none;\n border: none;\n}\n.grid-header-label {\n position: absolute;\n display: block;\n font-weight: bold;\n white-space: nowrap;\n}\n.grid-header-label--column {\n transform: translate(-50%, -100%);\n}\n.grid-header-label--row {\n transform: translate(-100%, -50%);\n}\n.grid-weight-handle--column {\n cursor: col-resize;\n}\n.grid-weight-handle--row {\n cursor: row-resize;\n}\n.grid-weight-handle:hover {\n stroke-opacity: 0.6;\n}\n");
29
+
30
+ // components/DefaultImageSelector.tsx
31
+ import { jsx } from "react/jsx-runtime";
32
+ var DefaultImageSelector = ({ images, selectedIndex, onSelect }) => /* @__PURE__ */ jsx(
33
+ "div",
34
+ {
35
+ style: {
36
+ position: "absolute",
37
+ top: 10,
38
+ right: 10,
39
+ zIndex: 1e3,
40
+ display: "flex",
41
+ gap: 8
42
+ },
43
+ children: images.map((image, index) => /* @__PURE__ */ jsx(
44
+ "button",
45
+ {
46
+ type: "button",
47
+ onClick: () => onSelect(index),
48
+ style: {
49
+ padding: "12px 20px",
50
+ fontSize: 16,
51
+ fontWeight: index === selectedIndex ? "bold" : "normal",
52
+ background: index === selectedIndex ? "#3388ff" : "white",
53
+ color: index === selectedIndex ? "white" : "black",
54
+ border: "1px solid #3388ff",
55
+ borderRadius: 6
56
+ },
57
+ children: image.name
58
+ },
59
+ image.name
60
+ ))
61
+ }
62
+ );
63
+
64
+ // components/ImageLayer.tsx
65
+ import { useEffect as useEffect2, useState as useState2 } from "react";
66
+ import { CRS } from "leaflet";
67
+ import { MapContainer, ImageOverlay } from "react-leaflet";
68
+
69
+ // components/Grid.tsx
70
+ import { useState } from "react";
71
+ import { Rectangle, Marker } from "react-leaflet";
72
+
73
+ // helpers/weights.ts
74
+ var resolveWeights = (count, overrides = {}) => Array.from({ length: count }, (_, i) => overrides[i] ?? 1);
75
+ var weightOffsets = (weights) => {
76
+ const offsets = [0];
77
+ weights.forEach((weight) => offsets.push(offsets[offsets.length - 1] + weight));
78
+ return offsets;
79
+ };
80
+ var MIN_WEIGHT = 0.2;
81
+ var applyWeightDelta = (weights, index, delta) => {
82
+ const a = weights[index];
83
+ const b = weights[index + 1];
84
+ const clampedDelta = Math.max(-(a - MIN_WEIGHT), Math.min(b - MIN_WEIGHT, delta));
85
+ const next = [...weights];
86
+ next[index] = a + clampedDelta;
87
+ next[index + 1] = b - clampedDelta;
88
+ return next;
89
+ };
90
+
91
+ // helpers/headerIcon.ts
92
+ import { divIcon } from "leaflet";
93
+ var headerIcon = (label, className) => divIcon({
94
+ html: `<span class="grid-header-label ${className}">${label}</span>`,
95
+ className: "grid-header-label-icon",
96
+ iconSize: [0, 0]
97
+ });
98
+
99
+ // helpers/cellAccessibility.ts
100
+ var getPathElement = (instance) => instance.getElement();
101
+ var bindCellAccessibility = (instance, label, selected, onActivate) => {
102
+ const el = instance && getPathElement(instance);
103
+ if (!el) {
104
+ return;
105
+ }
106
+ el.setAttribute("role", "button");
107
+ el.setAttribute("tabindex", "0");
108
+ el.setAttribute("aria-label", label);
109
+ el.setAttribute("aria-pressed", String(selected));
110
+ el.onkeydown = (e) => {
111
+ if (e.key === "Enter" || e.key === " ") {
112
+ e.preventDefault();
113
+ onActivate();
114
+ }
115
+ };
116
+ };
117
+
118
+ // components/WeightHandle.tsx
119
+ import { useRef } from "react";
120
+ import { Polyline, useMap } from "react-leaflet";
121
+ import { jsx as jsx2 } from "react/jsx-runtime";
122
+ var WeightHandle = ({ axis, boundary, length, onDrag, onDragEnd }) => {
123
+ const map = useMap();
124
+ const dragStartRef = useRef(null);
125
+ const positions = axis === "column" ? [[0, boundary], [length, boundary]] : [[boundary, 0], [boundary, length]];
126
+ const handleMouseDown = (event) => {
127
+ event.originalEvent.preventDefault();
128
+ map.dragging.disable();
129
+ dragStartRef.current = axis === "column" ? event.latlng.lng : event.latlng.lat;
130
+ const handleMouseMove = (moveEvent) => {
131
+ if (dragStartRef.current === null) {
132
+ return;
133
+ }
134
+ const latlng = map.mouseEventToLatLng(moveEvent);
135
+ const current = axis === "column" ? latlng.lng : latlng.lat;
136
+ onDrag(current - dragStartRef.current);
137
+ };
138
+ const handleMouseUp = () => {
139
+ dragStartRef.current = null;
140
+ map.dragging.enable();
141
+ onDragEnd();
142
+ window.removeEventListener("mousemove", handleMouseMove);
143
+ window.removeEventListener("mouseup", handleMouseUp);
144
+ };
145
+ window.addEventListener("mousemove", handleMouseMove);
146
+ window.addEventListener("mouseup", handleMouseUp);
147
+ };
148
+ return /* @__PURE__ */ jsx2(
149
+ Polyline,
150
+ {
151
+ positions,
152
+ pathOptions: {
153
+ color: "#3388ff",
154
+ weight: 6,
155
+ opacity: 0.15,
156
+ className: `grid-weight-handle grid-weight-handle--${axis}`
157
+ },
158
+ eventHandlers: { mousedown: handleMouseDown }
159
+ }
160
+ );
161
+ };
162
+
163
+ // components/Grid.tsx
164
+ import { jsx as jsx3 } from "react/jsx-runtime";
165
+ var Grid = ({
166
+ rows,
167
+ rowOffset,
168
+ rowWeights,
169
+ columnLabels,
170
+ columnWeights,
171
+ imgBounds,
172
+ selectedCells,
173
+ weightsEditable,
174
+ onColumnWeightsChange,
175
+ onRowWeightsChange,
176
+ onCellClick
177
+ }) => {
178
+ const [dragState, setDragState] = useState(null);
179
+ const [height, width] = imgBounds;
180
+ const displayColumnWeights = dragState?.axis === "column" ? applyWeightDelta(columnWeights, dragState.index, dragState.deltaWeight) : columnWeights;
181
+ const displayRowWeights = dragState?.axis === "row" ? applyWeightDelta(rowWeights, dragState.index, dragState.deltaWeight) : rowWeights;
182
+ const colOffsets = weightOffsets(displayColumnWeights);
183
+ const pxPerColWeightUnit = width / colOffsets[colOffsets.length - 1];
184
+ const colLeft = (colCount) => colOffsets[colCount] * pxPerColWeightUnit;
185
+ const colRight = (colCount) => colOffsets[colCount + 1] * pxPerColWeightUnit;
186
+ const rowOffsets = weightOffsets(displayRowWeights);
187
+ const pxPerRowWeightUnit = height / rowOffsets[rowOffsets.length - 1];
188
+ const rowBottom = (rowCount) => rowOffsets[rowCount] * pxPerRowWeightUnit;
189
+ const rowTop = (rowCount) => rowOffsets[rowCount + 1] * pxPerRowWeightUnit;
190
+ const cells = Array.from({ length: rows }).map(
191
+ (_, rowCount) => columnLabels.map((columnLabel, colCount) => {
192
+ const bounds = [
193
+ [rowTop(rowCount), colRight(colCount)],
194
+ [rowBottom(rowCount), colLeft(colCount)]
195
+ ];
196
+ const rowLabel = rowOffset + (rows - rowCount);
197
+ const cellId = `${rowLabel},${columnLabel}`;
198
+ const selected = selectedCells.has(cellId);
199
+ const activate = () => onCellClick?.({ col: columnLabel, row: rowLabel });
200
+ return /* @__PURE__ */ jsx3(
201
+ Rectangle,
202
+ {
203
+ ref: (instance) => bindCellAccessibility(instance, `${columnLabel}${rowLabel}`, selected, activate),
204
+ bounds,
205
+ pathOptions: {
206
+ fillColor: selected ? "red" : "transparent",
207
+ fillOpacity: selected ? 0.5 : 0,
208
+ weight: 1.5
209
+ },
210
+ eventHandlers: {
211
+ click: activate
212
+ }
213
+ },
214
+ cellId
215
+ );
216
+ })
217
+ );
218
+ const columnHeaders = columnLabels.map((columnLabel, colCount) => /* @__PURE__ */ jsx3(
219
+ Marker,
220
+ {
221
+ position: [height, (colLeft(colCount) + colRight(colCount)) / 2],
222
+ icon: headerIcon(columnLabel, "grid-header-label--column"),
223
+ interactive: false
224
+ },
225
+ `col-${columnLabel}`
226
+ ));
227
+ const rowHeaders = Array.from({ length: rows }).map((_, rowCount) => {
228
+ const rowLabel = rowOffset + (rows - rowCount);
229
+ return /* @__PURE__ */ jsx3(
230
+ Marker,
231
+ {
232
+ position: [(rowBottom(rowCount) + rowTop(rowCount)) / 2, -7],
233
+ icon: headerIcon(String(rowLabel), "grid-header-label--row"),
234
+ interactive: false
235
+ },
236
+ `row-${rowLabel}`
237
+ );
238
+ });
239
+ const columnHandles = weightsEditable ? columnLabels.slice(0, -1).map((_, index) => /* @__PURE__ */ jsx3(
240
+ WeightHandle,
241
+ {
242
+ axis: "column",
243
+ boundary: colRight(index),
244
+ length: height,
245
+ onDrag: (deltaUnits) => setDragState({ axis: "column", index, deltaWeight: deltaUnits / pxPerColWeightUnit }),
246
+ onDragEnd: () => {
247
+ onColumnWeightsChange?.(displayColumnWeights);
248
+ setDragState(null);
249
+ }
250
+ },
251
+ `col-handle-${index}`
252
+ )) : [];
253
+ const rowHandles = weightsEditable ? Array.from({ length: rows - 1 }).map((_, index) => /* @__PURE__ */ jsx3(
254
+ WeightHandle,
255
+ {
256
+ axis: "row",
257
+ boundary: rowTop(index),
258
+ length: width,
259
+ onDrag: (deltaUnits) => setDragState({ axis: "row", index, deltaWeight: deltaUnits / pxPerRowWeightUnit }),
260
+ onDragEnd: () => {
261
+ onRowWeightsChange?.(displayRowWeights);
262
+ setDragState(null);
263
+ }
264
+ },
265
+ `row-handle-${index}`
266
+ )) : [];
267
+ return [...cells, columnHeaders, rowHeaders, columnHandles, rowHandles];
268
+ };
269
+
270
+ // components/ZoomController.tsx
271
+ import { useEffect, useRef as useRef2 } from "react";
272
+ import { useMap as useMap2 } from "react-leaflet";
273
+ var ZoomController = ({ zoomMultiplier }) => {
274
+ const map = useMap2();
275
+ const baseZoomRef = useRef2(null);
276
+ useEffect(() => {
277
+ if (baseZoomRef.current === null) {
278
+ baseZoomRef.current = map.getZoom();
279
+ }
280
+ map.setZoom(baseZoomRef.current + Math.log2(zoomMultiplier));
281
+ }, [map, zoomMultiplier]);
282
+ return null;
283
+ };
284
+
285
+ // helpers/imageBounds.ts
286
+ var imageBounds = (src, containerWidth, containerHeight) => new Promise((resolve, reject) => {
287
+ const img = new Image();
288
+ img.src = src;
289
+ img.onload = () => {
290
+ const scale = Math.min(containerWidth / img.width, containerHeight / img.height);
291
+ resolve([img.height * scale, img.width * scale]);
292
+ };
293
+ img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
294
+ });
295
+
296
+ // helpers/colNames.ts
297
+ var toColumnLetter = (n) => {
298
+ let result = "";
299
+ let remaining = n;
300
+ while (remaining > 0) {
301
+ const remainder = (remaining - 1) % 26;
302
+ result = String.fromCharCode(65 + remainder) + result;
303
+ remaining = Math.floor((remaining - 1) / 26);
304
+ }
305
+ return result;
306
+ };
307
+ var colNames = (startIndex, count) => Array.from({ length: count }, (_, i) => toColumnLetter(startIndex + i + 1));
308
+
309
+ // components/ImageLayer.tsx
310
+ import { jsx as jsx4, jsxs } from "react/jsx-runtime";
311
+ var ImageLayer = ({
312
+ image,
313
+ colOffset,
314
+ rowOffset,
315
+ width,
316
+ height,
317
+ alt,
318
+ selectedCells,
319
+ maxBoundsPadding = 100,
320
+ zoomMultiplier = 1,
321
+ weightsEditable,
322
+ onColumnWeightsChange,
323
+ onRowWeightsChange,
324
+ onCellClick
325
+ }) => {
326
+ const [imgBounds, setImgBounds] = useState2(null);
327
+ const [loadError, setLoadError] = useState2(null);
328
+ useEffect2(() => {
329
+ imageBounds(image.src, width, height).then(setImgBounds).catch(setLoadError);
330
+ }, [image.src, width, height]);
331
+ if (loadError) {
332
+ throw loadError;
333
+ }
334
+ if (!imgBounds) {
335
+ return null;
336
+ }
337
+ const [imgHeight, imgWidth] = imgBounds;
338
+ const bounds = [[0, 0], imgBounds];
339
+ const maxBounds = [
340
+ [-maxBoundsPadding, -maxBoundsPadding],
341
+ [imgHeight + maxBoundsPadding, imgWidth + maxBoundsPadding]
342
+ ];
343
+ const columnLabels = colNames(colOffset, image.columns);
344
+ const columnWeights = resolveWeights(image.columns, image.columnWeights);
345
+ const rowWeights = resolveWeights(image.rows, image.rowWeights);
346
+ return /* @__PURE__ */ jsxs(
347
+ MapContainer,
348
+ {
349
+ crs: CRS.Simple,
350
+ bounds,
351
+ boundsOptions: { padding: [50, 50] },
352
+ maxBounds,
353
+ maxBoundsViscosity: 0.5,
354
+ style: { width, height },
355
+ maxZoom: 2,
356
+ children: [
357
+ /* @__PURE__ */ jsx4(ZoomController, { zoomMultiplier }),
358
+ /* @__PURE__ */ jsx4(
359
+ Grid,
360
+ {
361
+ rows: image.rows,
362
+ rowOffset,
363
+ rowWeights,
364
+ columnLabels,
365
+ columnWeights,
366
+ imgBounds,
367
+ selectedCells,
368
+ weightsEditable,
369
+ onColumnWeightsChange,
370
+ onRowWeightsChange,
371
+ onCellClick
372
+ }
373
+ ),
374
+ /* @__PURE__ */ jsx4(ImageOverlay, { url: image.src, bounds, alt })
375
+ ]
376
+ }
377
+ );
378
+ };
379
+
380
+ // MappedImage.tsx
381
+ import { jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
382
+ var MappedImage = (props) => {
383
+ if (props.images.length === 0) {
384
+ throw new Error("MappedImage requires at least one entry in `images`.");
385
+ }
386
+ const invalidImage = props.images.find((image) => image.rows <= 0 || image.columns <= 0);
387
+ if (invalidImage) {
388
+ throw new Error(
389
+ `MappedImage: image "${invalidImage.name}" must have rows > 0 and columns > 0.`
390
+ );
391
+ }
392
+ const [internalSelectedCells, setInternalSelectedCells] = useState3(
393
+ props.selectedCells ?? /* @__PURE__ */ new Set()
394
+ );
395
+ const isSelectionControlled = props.selectedCells !== void 0;
396
+ const selectedCells = isSelectionControlled ? props.selectedCells : internalSelectedCells;
397
+ const [selectedImageIndex, setSelectedImageIndex] = useState3(
398
+ props.selectedImageIndex ?? 0
399
+ );
400
+ const selectedImage = props.images[selectedImageIndex];
401
+ const colOffset = props.images.slice(0, selectedImageIndex).reduce((sum, image) => sum + image.columns, 0);
402
+ const rowOffset = props.images.slice(0, selectedImageIndex).reduce((sum, image) => sum + image.rows, 0);
403
+ const handleSelectImage = (index) => {
404
+ setSelectedImageIndex(index);
405
+ props.onSelectedImageIndexChange?.(index);
406
+ };
407
+ const handleCellClick = ({ row, col }) => {
408
+ const cellId = `${row},${col}`;
409
+ const next = new Set(selectedCells);
410
+ if (next.has(cellId)) {
411
+ next.delete(cellId);
412
+ } else {
413
+ next.add(cellId);
414
+ }
415
+ if (!isSelectionControlled) {
416
+ setInternalSelectedCells(next);
417
+ }
418
+ props.onSelectedCellsChange?.(next);
419
+ props.onCellClick?.({ row, col });
420
+ };
421
+ const renderSelector = props.renderImageSelector ?? ((selectorProps) => /* @__PURE__ */ jsx5(DefaultImageSelector, { ...selectorProps }));
422
+ return /* @__PURE__ */ jsxs2("div", { style: { position: "relative", width: props.width, height: props.height }, children: [
423
+ /* @__PURE__ */ jsx5(
424
+ ImageLayer,
425
+ {
426
+ image: selectedImage,
427
+ colOffset,
428
+ rowOffset,
429
+ width: props.width,
430
+ height: props.height,
431
+ alt: props.alt,
432
+ selectedCells,
433
+ maxBoundsPadding: props.maxBoundsPadding,
434
+ zoomMultiplier: props.zoomMultiplier,
435
+ weightsEditable: props.weightsEditable,
436
+ onColumnWeightsChange: props.onColumnWeightsChange,
437
+ onRowWeightsChange: props.onRowWeightsChange,
438
+ onCellClick: handleCellClick
439
+ },
440
+ selectedImage.src
441
+ ),
442
+ renderSelector({
443
+ images: props.images,
444
+ selectedIndex: selectedImageIndex,
445
+ onSelect: handleSelectImage
446
+ })
447
+ ] });
448
+ };
449
+ export {
450
+ MappedImage
451
+ };
452
+ //# sourceMappingURL=MappedImage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../MappedImage.tsx","#style-inject:#style-inject","../MappedImage.css","../components/DefaultImageSelector.tsx","../components/ImageLayer.tsx","../components/Grid.tsx","../helpers/weights.ts","../helpers/headerIcon.ts","../helpers/cellAccessibility.ts","../components/WeightHandle.tsx","../components/ZoomController.tsx","../helpers/imageBounds.ts","../helpers/colNames.ts"],"sourcesContent":["import { useState } from \"react\";\nimport \"leaflet/dist/leaflet.css\";\nimport \"./MappedImage.css\";\nimport type { ICellClickProps, ImageSelectorProps, MappedImageProps } from \"./types\";\nimport { DefaultImageSelector } from \"./components/DefaultImageSelector\";\nimport { ImageLayer } from \"./components/ImageLayer\";\n\n// Type-only re-export; doesn't add a non-component export for fast-refresh purposes.\n// eslint-disable-next-line react-refresh/only-export-components\nexport * from \"./types\";\n\nexport const MappedImage = (props: MappedImageProps) => {\n if (props.images.length === 0) {\n throw new Error(\"MappedImage requires at least one entry in `images`.\");\n }\n\n const invalidImage = props.images.find((image) => image.rows <= 0 || image.columns <= 0);\n if (invalidImage) {\n throw new Error(\n `MappedImage: image \"${invalidImage.name}\" must have rows > 0 and columns > 0.`,\n );\n }\n\n const [internalSelectedCells, setInternalSelectedCells] = useState<Set<string>>(\n props.selectedCells ?? new Set(),\n );\n const isSelectionControlled = props.selectedCells !== undefined;\n const selectedCells = isSelectionControlled ? props.selectedCells! : internalSelectedCells;\n\n const [selectedImageIndex, setSelectedImageIndex] = useState(\n props.selectedImageIndex ?? 0,\n );\n\n const selectedImage = props.images[selectedImageIndex];\n const colOffset = props.images\n .slice(0, selectedImageIndex)\n .reduce((sum, image) => sum + image.columns, 0);\n const rowOffset = props.images\n .slice(0, selectedImageIndex)\n .reduce((sum, image) => sum + image.rows, 0);\n\n const handleSelectImage = (index: number) => {\n setSelectedImageIndex(index);\n props.onSelectedImageIndexChange?.(index);\n };\n\n const handleCellClick = ({ row, col }: ICellClickProps) => {\n const cellId = `${row},${col}`;\n const next = new Set(selectedCells);\n if (next.has(cellId)) {\n next.delete(cellId);\n } else {\n next.add(cellId);\n }\n\n if (!isSelectionControlled) {\n setInternalSelectedCells(next);\n }\n props.onSelectedCellsChange?.(next);\n props.onCellClick?.({ row, col });\n };\n\n const renderSelector = props.renderImageSelector ?? (\n (selectorProps: ImageSelectorProps) => <DefaultImageSelector {...selectorProps} />\n );\n\n return (\n <div style={{ position: \"relative\", width: props.width, height: props.height }}>\n <ImageLayer\n key={selectedImage.src}\n image={selectedImage}\n colOffset={colOffset}\n rowOffset={rowOffset}\n width={props.width}\n height={props.height}\n alt={props.alt}\n selectedCells={selectedCells}\n maxBoundsPadding={props.maxBoundsPadding}\n zoomMultiplier={props.zoomMultiplier}\n weightsEditable={props.weightsEditable}\n onColumnWeightsChange={props.onColumnWeightsChange}\n onRowWeightsChange={props.onRowWeightsChange}\n onCellClick={handleCellClick}\n />\n {renderSelector({\n images: props.images,\n selectedIndex: selectedImageIndex,\n onSelect: handleSelectImage,\n })}\n </div>\n );\n};\n","\n export default function styleInject(css, { insertAt } = {}) {\n if (!css || typeof document === 'undefined') return\n \n const head = document.head || document.getElementsByTagName('head')[0]\n const style = document.createElement('style')\n style.type = 'text/css'\n \n if (insertAt === 'top') {\n if (head.firstChild) {\n head.insertBefore(style, head.firstChild)\n } else {\n head.appendChild(style)\n }\n } else {\n head.appendChild(style)\n }\n \n if (style.styleSheet) {\n style.styleSheet.cssText = css\n } else {\n style.appendChild(document.createTextNode(css))\n }\n }\n ","import styleInject from '#style-inject';styleInject(\".grid-header-label-icon {\\n background: none;\\n border: none;\\n}\\n.grid-header-label {\\n position: absolute;\\n display: block;\\n font-weight: bold;\\n white-space: nowrap;\\n}\\n.grid-header-label--column {\\n transform: translate(-50%, -100%);\\n}\\n.grid-header-label--row {\\n transform: translate(-100%, -50%);\\n}\\n.grid-weight-handle--column {\\n cursor: col-resize;\\n}\\n.grid-weight-handle--row {\\n cursor: row-resize;\\n}\\n.grid-weight-handle:hover {\\n stroke-opacity: 0.6;\\n}\\n\")","import type { ImageSelectorProps } from \"../types\";\n\nexport const DefaultImageSelector = ({ images, selectedIndex, onSelect }: ImageSelectorProps) => (\n <div\n style={{\n position: \"absolute\",\n top: 10,\n right: 10,\n zIndex: 1000,\n display: \"flex\",\n gap: 8,\n }}\n >\n {images.map((image, index) => (\n <button\n key={image.name}\n type=\"button\"\n onClick={() => onSelect(index)}\n style={{\n padding: \"12px 20px\",\n fontSize: 16,\n fontWeight: index === selectedIndex ? \"bold\" : \"normal\",\n background: index === selectedIndex ? \"#3388ff\" : \"white\",\n color: index === selectedIndex ? \"white\" : \"black\",\n border: \"1px solid #3388ff\",\n borderRadius: 6,\n }}\n >\n {image.name}\n </button>\n ))}\n </div>\n);\n","import { useEffect, useState } from \"react\";\nimport { CRS, type LatLngBoundsExpression, type LatLngTuple } from \"leaflet\";\nimport { MapContainer, ImageOverlay } from \"react-leaflet\";\nimport type { ImageLayerProps } from \"../types\";\nimport { Grid } from \"./Grid\";\nimport { ZoomController } from \"./ZoomController\";\nimport { imageBounds } from \"../helpers/imageBounds\";\nimport { colNames } from \"../helpers/colNames\";\nimport { resolveWeights } from \"../helpers/weights\";\n\nexport const ImageLayer = ({\n image,\n colOffset,\n rowOffset,\n width,\n height,\n alt,\n selectedCells,\n maxBoundsPadding = 100,\n zoomMultiplier = 1,\n weightsEditable,\n onColumnWeightsChange,\n onRowWeightsChange,\n onCellClick,\n}: ImageLayerProps) => {\n const [imgBounds, setImgBounds] = useState<LatLngTuple | null>(null);\n const [loadError, setLoadError] = useState<Error | null>(null);\n\n useEffect(() => {\n imageBounds(image.src, width, height).then(setImgBounds).catch(setLoadError);\n }, [image.src, width, height]);\n\n if (loadError) {\n throw loadError;\n }\n\n if (!imgBounds) {\n return null;\n }\n\n const [imgHeight, imgWidth] = imgBounds;\n const bounds: LatLngBoundsExpression = [[0, 0], imgBounds];\n const maxBounds: LatLngBoundsExpression = [\n [-maxBoundsPadding , -maxBoundsPadding],\n [imgHeight + maxBoundsPadding, imgWidth + maxBoundsPadding],\n ];\n const columnLabels = colNames(colOffset, image.columns);\n const columnWeights = resolveWeights(image.columns, image.columnWeights);\n const rowWeights = resolveWeights(image.rows, image.rowWeights);\n\n return (\n <MapContainer\n crs={CRS.Simple}\n bounds={bounds}\n boundsOptions={{ padding: [50, 50] }}\n maxBounds={maxBounds}\n maxBoundsViscosity={0.5}\n style={{ width, height }}\n maxZoom={2}\n >\n <ZoomController zoomMultiplier={zoomMultiplier} />\n <Grid\n rows={image.rows}\n rowOffset={rowOffset}\n rowWeights={rowWeights}\n columnLabels={columnLabels}\n columnWeights={columnWeights}\n imgBounds={imgBounds}\n selectedCells={selectedCells}\n weightsEditable={weightsEditable}\n onColumnWeightsChange={onColumnWeightsChange}\n onRowWeightsChange={onRowWeightsChange}\n onCellClick={onCellClick}\n />\n <ImageOverlay url={image.src} bounds={bounds} alt={alt} />\n </MapContainer>\n );\n};\n","import { useState } from \"react\";\nimport { Rectangle, Marker } from \"react-leaflet\";\nimport type { LatLngBoundsExpression } from \"leaflet\";\nimport type { GridProps } from \"../types\";\nimport { applyWeightDelta, weightOffsets } from \"../helpers/weights\";\nimport { headerIcon } from \"../helpers/headerIcon\";\nimport { bindCellAccessibility } from \"../helpers/cellAccessibility\";\nimport { WeightHandle } from \"./WeightHandle\";\n\ntype DragState = { axis: \"column\" | \"row\"; index: number; deltaWeight: number };\n\nexport const Grid = ({\n rows,\n rowOffset,\n rowWeights,\n columnLabels,\n columnWeights,\n imgBounds,\n selectedCells,\n weightsEditable,\n onColumnWeightsChange,\n onRowWeightsChange,\n onCellClick,\n}: GridProps) => {\n const [dragState, setDragState] = useState<DragState | null>(null);\n const [height, width] = imgBounds;\n\n const displayColumnWeights =\n dragState?.axis === \"column\"\n ? applyWeightDelta(columnWeights, dragState.index, dragState.deltaWeight)\n : columnWeights;\n const displayRowWeights =\n dragState?.axis === \"row\"\n ? applyWeightDelta(rowWeights, dragState.index, dragState.deltaWeight)\n : rowWeights;\n\n const colOffsets = weightOffsets(displayColumnWeights);\n const pxPerColWeightUnit = width / colOffsets[colOffsets.length - 1];\n const colLeft = (colCount: number) => colOffsets[colCount] * pxPerColWeightUnit;\n const colRight = (colCount: number) => colOffsets[colCount + 1] * pxPerColWeightUnit;\n\n const rowOffsets = weightOffsets(displayRowWeights);\n const pxPerRowWeightUnit = height / rowOffsets[rowOffsets.length - 1];\n const rowBottom = (rowCount: number) => rowOffsets[rowCount] * pxPerRowWeightUnit;\n const rowTop = (rowCount: number) => rowOffsets[rowCount + 1] * pxPerRowWeightUnit;\n\n const cells = Array.from({ length: rows }).map((_, rowCount) =>\n columnLabels.map((columnLabel, colCount) => {\n const bounds: LatLngBoundsExpression = [\n [rowTop(rowCount), colRight(colCount)],\n [rowBottom(rowCount), colLeft(colCount)],\n ];\n\n const rowLabel = rowOffset + (rows - rowCount);\n const cellId = `${rowLabel},${columnLabel}`;\n const selected = selectedCells.has(cellId);\n const activate = () => onCellClick?.({ col: columnLabel, row: rowLabel });\n\n return (\n <Rectangle\n key={cellId}\n ref={(instance) =>\n bindCellAccessibility(instance, `${columnLabel}${rowLabel}`, selected, activate)\n }\n bounds={bounds}\n pathOptions={{\n fillColor: selected ? \"red\" : \"transparent\",\n fillOpacity: selected ? 0.5 : 0,\n weight: 1.5,\n }}\n eventHandlers={{\n click: activate,\n }}\n />\n );\n }),\n );\n\n const columnHeaders = columnLabels.map((columnLabel, colCount) => (\n <Marker\n key={`col-${columnLabel}`}\n position={[height, (colLeft(colCount) + colRight(colCount)) / 2]}\n icon={headerIcon(columnLabel, \"grid-header-label--column\")}\n interactive={false}\n />\n ));\n\n const rowHeaders = Array.from({ length: rows }).map((_, rowCount) => {\n const rowLabel = rowOffset + (rows - rowCount);\n return (\n <Marker\n key={`row-${rowLabel}`}\n position={[(rowBottom(rowCount) + rowTop(rowCount)) / 2, -7]}\n icon={headerIcon(String(rowLabel), \"grid-header-label--row\")}\n interactive={false}\n />\n );\n });\n\n const columnHandles = weightsEditable\n ? columnLabels.slice(0, -1).map((_, index) => (\n <WeightHandle\n key={`col-handle-${index}`}\n axis=\"column\"\n boundary={colRight(index)}\n length={height}\n onDrag={(deltaUnits) =>\n setDragState({ axis: \"column\", index, deltaWeight: deltaUnits / pxPerColWeightUnit })\n }\n onDragEnd={() => {\n onColumnWeightsChange?.(displayColumnWeights);\n setDragState(null);\n }}\n />\n ))\n : [];\n\n const rowHandles = weightsEditable\n ? Array.from({ length: rows - 1 }).map((_, index) => (\n <WeightHandle\n key={`row-handle-${index}`}\n axis=\"row\"\n boundary={rowTop(index)}\n length={width}\n onDrag={(deltaUnits) =>\n setDragState({ axis: \"row\", index, deltaWeight: deltaUnits / pxPerRowWeightUnit })\n }\n onDragEnd={() => {\n onRowWeightsChange?.(displayRowWeights);\n setDragState(null);\n }}\n />\n ))\n : [];\n\n return [...cells, columnHeaders, rowHeaders, columnHandles, rowHandles];\n};\n","export const resolveWeights = (\n count: number,\n overrides: Record<number, number> = {},\n): number[] => Array.from({ length: count }, (_, i) => overrides[i] ?? 1);\n\n/** Cumulative leading edge (in weight units) of each index, plus the total at the end. */\nexport const weightOffsets = (weights: number[]): number[] => {\n const offsets = [0];\n weights.forEach((weight) => offsets.push(offsets[offsets.length - 1] + weight));\n return offsets;\n};\n\nconst MIN_WEIGHT = 0.2;\n\n/** Shifts weight from index+1 into index (or back, for a negative delta), clamped so neither drops below MIN_WEIGHT. Total weight is preserved. */\nexport const applyWeightDelta = (weights: number[], index: number, delta: number): number[] => {\n const a = weights[index];\n const b = weights[index + 1];\n const clampedDelta = Math.max(-(a - MIN_WEIGHT), Math.min(b - MIN_WEIGHT, delta));\n\n const next = [...weights];\n next[index] = a + clampedDelta;\n next[index + 1] = b - clampedDelta;\n return next;\n};\n","import { divIcon } from \"leaflet\";\n\nexport const headerIcon = (label: string, className: string) =>\n divIcon({\n html: `<span class=\"grid-header-label ${className}\">${label}</span>`,\n className: \"grid-header-label-icon\",\n iconSize: [0, 0],\n });\n","import type { Rectangle as LeafletRectangle } from \"leaflet\";\n\n// Path.getElement() returns the underlying SVG node but isn't declared in @types/leaflet, hence the cast.\nconst getPathElement = (instance: LeafletRectangle) =>\n (instance as unknown as { getElement(): SVGElement | undefined }).getElement();\n\nexport const bindCellAccessibility = (\n instance: LeafletRectangle | null,\n label: string,\n selected: boolean,\n onActivate: () => void,\n) => {\n const el = instance && getPathElement(instance);\n if (!el) {\n return;\n }\n\n el.setAttribute(\"role\", \"button\");\n el.setAttribute(\"tabindex\", \"0\");\n el.setAttribute(\"aria-label\", label);\n el.setAttribute(\"aria-pressed\", String(selected));\n el.onkeydown = (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onActivate();\n }\n };\n};\n","import { useRef } from \"react\";\nimport { Polyline, useMap } from \"react-leaflet\";\nimport type { LatLngExpression, LeafletMouseEvent } from \"leaflet\";\n\ntype WeightHandleProps = {\n axis: \"column\" | \"row\";\n /** Position along the cross-axis: lng for a column boundary, lat for a row boundary. */\n boundary: number;\n /** Full span of the line: image height for column boundaries, image width for row boundaries. */\n length: number;\n onDrag: (deltaUnits: number) => void;\n onDragEnd: () => void;\n};\n\nexport const WeightHandle = ({ axis, boundary, length, onDrag, onDragEnd }: WeightHandleProps) => {\n const map = useMap();\n const dragStartRef = useRef<number | null>(null);\n\n const positions: LatLngExpression[] =\n axis === \"column\" ? [[0, boundary], [length, boundary]] : [[boundary, 0], [boundary, length]];\n\n const handleMouseDown = (event: LeafletMouseEvent) => {\n event.originalEvent.preventDefault();\n map.dragging.disable();\n dragStartRef.current = axis === \"column\" ? event.latlng.lng : event.latlng.lat;\n\n const handleMouseMove = (moveEvent: MouseEvent) => {\n if (dragStartRef.current === null) {\n return;\n }\n const latlng = map.mouseEventToLatLng(moveEvent);\n const current = axis === \"column\" ? latlng.lng : latlng.lat;\n onDrag(current - dragStartRef.current);\n };\n\n const handleMouseUp = () => {\n dragStartRef.current = null;\n map.dragging.enable();\n onDragEnd();\n window.removeEventListener(\"mousemove\", handleMouseMove);\n window.removeEventListener(\"mouseup\", handleMouseUp);\n };\n\n window.addEventListener(\"mousemove\", handleMouseMove);\n window.addEventListener(\"mouseup\", handleMouseUp);\n };\n\n return (\n <Polyline\n positions={positions}\n pathOptions={{\n color: \"#3388ff\",\n weight: 6,\n opacity: 0.15,\n className: `grid-weight-handle grid-weight-handle--${axis}`,\n }}\n eventHandlers={{ mousedown: handleMouseDown }}\n />\n );\n};\n","import { useEffect, useRef } from \"react\";\nimport { useMap } from \"react-leaflet\";\n\ntype ZoomControllerProps = {\n zoomMultiplier: number;\n};\n\nexport const ZoomController = ({ zoomMultiplier }: ZoomControllerProps) => {\n const map = useMap();\n const baseZoomRef = useRef<number | null>(null);\n\n useEffect(() => {\n // Capture the zoom react-leaflet's fitBounds already settled on, before any multiplier is applied.\n if (baseZoomRef.current === null) {\n baseZoomRef.current = map.getZoom();\n }\n\n map.setZoom(baseZoomRef.current + Math.log2(zoomMultiplier));\n }, [map, zoomMultiplier]);\n\n return null;\n};\n","import type { LatLngTuple } from \"leaflet\";\r\n\r\nexport const imageBounds = (src: string, containerWidth: number, containerHeight: number): Promise<LatLngTuple> =>\r\n new Promise((resolve, reject) => {\r\n const img = new Image();\r\n img.src = src;\r\n img.onload = () => {\r\n const scale = Math.min(containerWidth / img.width, containerHeight / img.height);\r\n resolve([img.height * scale, img.width * scale]);\r\n };\r\n img.onerror = () => reject(new Error(`Failed to load image: ${src}`));\r\n });","const toColumnLetter = (n: number): string => {\n let result = \"\";\n let remaining = n;\n\n while (remaining > 0) {\n const remainder = (remaining - 1) % 26;\n result = String.fromCharCode(65 + remainder) + result;\n remaining = Math.floor((remaining - 1) / 26);\n }\n\n return result;\n};\n\nexport const colNames = (startIndex: number, count: number): string[] =>\n Array.from({ length: count }, (_, i) => toColumnLetter(startIndex + i + 1));\n"],"mappings":"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","names":["useState","useEffect","useState","jsx","jsx","useRef","useMap","jsx","useState","useEffect","jsx","jsxs","useState"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mapped-image",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "A React component that overlays a clickable, weighted row/column grid on top of one or more images, built on react-leaflet.",
@@ -21,13 +21,22 @@
21
21
  "url": "https://github.com/notetuwu/mapped-image/issues"
22
22
  },
23
23
  "type": "module",
24
- "main": "MappedImage.tsx",
25
- "types": "MappedImage.tsx",
24
+ "main": "dist/MappedImage.js",
25
+ "module": "dist/MappedImage.js",
26
+ "types": "dist/MappedImage.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/MappedImage.d.ts",
30
+ "import": "./dist/MappedImage.js"
31
+ }
32
+ },
26
33
  "files": [
27
- "**/*.ts",
28
- "**/*.tsx",
29
- "**/*.css"
34
+ "dist"
30
35
  ],
36
+ "scripts": {
37
+ "build": "tsup",
38
+ "prepare": "tsup"
39
+ },
31
40
  "peerDependencies": {
32
41
  "react": "^19.0.0",
33
42
  "react-dom": "^19.0.0"
@@ -36,5 +45,8 @@
36
45
  "leaflet": "^1.9.4",
37
46
  "react-leaflet": "^5.0.0",
38
47
  "@types/leaflet": "^1.9.21"
48
+ },
49
+ "devDependencies": {
50
+ "tsup": "^8.3.5"
39
51
  }
40
52
  }
package/MappedImage.css DELETED
@@ -1,19 +0,0 @@
1
- .grid-header-label-icon {
2
- background: none;
3
- border: none;
4
- }
5
-
6
- .grid-header-label {
7
- position: absolute;
8
- display: block;
9
- font-weight: bold;
10
- white-space: nowrap;
11
- }
12
-
13
- .grid-header-label--column {
14
- transform: translate(-50%, -100%);
15
- }
16
-
17
- .grid-header-label--row {
18
- transform: translate(-100%, -50%);
19
- }
package/MappedImage.tsx DELETED
@@ -1,84 +0,0 @@
1
- import { useState } from "react";
2
- import "leaflet/dist/leaflet.css";
3
- import "./MappedImage.css";
4
- import type { ICellClickProps, ImageSelectorProps, MappedImageProps } from "./types";
5
- import { DefaultImageSelector } from "./components/DefaultImageSelector";
6
- import { ImageLayer } from "./components/ImageLayer";
7
-
8
- export const MappedImage = (props: MappedImageProps) => {
9
- if (props.images.length === 0) {
10
- throw new Error("MappedImage requires at least one entry in `images`.");
11
- }
12
-
13
- const invalidImage = props.images.find((image) => image.rows <= 0 || image.columns <= 0);
14
- if (invalidImage) {
15
- throw new Error(
16
- `MappedImage: image "${invalidImage.name}" must have rows > 0 and columns > 0.`,
17
- );
18
- }
19
-
20
- const [internalSelectedCells, setInternalSelectedCells] = useState<Set<string>>(
21
- props.selectedCells ?? new Set(),
22
- );
23
- const isSelectionControlled = props.selectedCells !== undefined;
24
- const selectedCells = isSelectionControlled ? props.selectedCells! : internalSelectedCells;
25
-
26
- const [selectedImageIndex, setSelectedImageIndex] = useState(
27
- props.selectedImageIndex ?? 0,
28
- );
29
-
30
- const selectedImage = props.images[selectedImageIndex];
31
- const colOffset = props.images
32
- .slice(0, selectedImageIndex)
33
- .reduce((sum, image) => sum + image.columns, 0);
34
- const rowOffset = props.images
35
- .slice(0, selectedImageIndex)
36
- .reduce((sum, image) => sum + image.rows, 0);
37
-
38
- const handleSelectImage = (index: number) => {
39
- setSelectedImageIndex(index);
40
- props.onSelectedImageIndexChange?.(index);
41
- };
42
-
43
- const handleCellClick = ({ row, col }: ICellClickProps) => {
44
- const cellId = `${row},${col}`;
45
- const next = new Set(selectedCells);
46
- if (next.has(cellId)) {
47
- next.delete(cellId);
48
- } else {
49
- next.add(cellId);
50
- }
51
-
52
- if (!isSelectionControlled) {
53
- setInternalSelectedCells(next);
54
- }
55
- props.onSelectedCellsChange?.(next);
56
- props.onCellClick?.({ row, col });
57
- };
58
-
59
- const renderSelector = props.renderImageSelector ?? (
60
- (selectorProps: ImageSelectorProps) => <DefaultImageSelector {...selectorProps} />
61
- );
62
-
63
- return (
64
- <div style={{ position: "relative", width: props.width, height: props.height }}>
65
- <ImageLayer
66
- key={selectedImage.src}
67
- image={selectedImage}
68
- colOffset={colOffset}
69
- rowOffset={rowOffset}
70
- width={props.width}
71
- height={props.height}
72
- alt={props.alt}
73
- selectedCells={selectedCells}
74
- maxBoundsPadding={props.maxBoundsPadding}
75
- onCellClick={handleCellClick}
76
- />
77
- {renderSelector({
78
- images: props.images,
79
- selectedIndex: selectedImageIndex,
80
- onSelect: handleSelectImage,
81
- })}
82
- </div>
83
- );
84
- };
@@ -1,33 +0,0 @@
1
- import type { ImageSelectorProps } from "../types";
2
-
3
- export const DefaultImageSelector = ({ images, selectedIndex, onSelect }: ImageSelectorProps) => (
4
- <div
5
- style={{
6
- position: "absolute",
7
- top: 10,
8
- right: 10,
9
- zIndex: 1000,
10
- display: "flex",
11
- gap: 8,
12
- }}
13
- >
14
- {images.map((image, index) => (
15
- <button
16
- key={image.name}
17
- type="button"
18
- onClick={() => onSelect(index)}
19
- style={{
20
- padding: "12px 20px",
21
- fontSize: 16,
22
- fontWeight: index === selectedIndex ? "bold" : "normal",
23
- background: index === selectedIndex ? "#3388ff" : "white",
24
- color: index === selectedIndex ? "white" : "black",
25
- border: "1px solid #3388ff",
26
- borderRadius: 6,
27
- }}
28
- >
29
- {image.name}
30
- </button>
31
- ))}
32
- </div>
33
- );
@@ -1,84 +0,0 @@
1
- import { Rectangle, Marker } from "react-leaflet";
2
- import type { LatLngBoundsExpression } from "leaflet";
3
- import type { GridProps } from "../types";
4
- import { weightOffsets } from "../helpers/weights";
5
- import { headerIcon } from "../helpers/headerIcon";
6
- import { bindCellAccessibility } from "../helpers/cellAccessibility";
7
-
8
- export const Grid = ({
9
- rows,
10
- rowOffset,
11
- rowWeights,
12
- columnLabels,
13
- columnWeights,
14
- imgBounds,
15
- selectedCells,
16
- onCellClick,
17
- }: GridProps) => {
18
- const [height, width] = imgBounds;
19
-
20
- const colOffsets = weightOffsets(columnWeights);
21
- const pxPerColWeightUnit = width / colOffsets[colOffsets.length - 1];
22
- const colLeft = (colCount: number) => colOffsets[colCount] * pxPerColWeightUnit;
23
- const colRight = (colCount: number) => colOffsets[colCount + 1] * pxPerColWeightUnit;
24
-
25
- const rowOffsets = weightOffsets(rowWeights);
26
- const pxPerRowWeightUnit = height / rowOffsets[rowOffsets.length - 1];
27
- const rowBottom = (rowCount: number) => rowOffsets[rowCount] * pxPerRowWeightUnit;
28
- const rowTop = (rowCount: number) => rowOffsets[rowCount + 1] * pxPerRowWeightUnit;
29
-
30
- const cells = Array.from({ length: rows }).map((_, rowCount) =>
31
- columnLabels.map((columnLabel, colCount) => {
32
- const bounds: LatLngBoundsExpression = [
33
- [rowTop(rowCount), colRight(colCount)],
34
- [rowBottom(rowCount), colLeft(colCount)],
35
- ];
36
-
37
- const rowLabel = rowOffset + (rows - rowCount);
38
- const cellId = `${rowLabel},${columnLabel}`;
39
- const selected = selectedCells.has(cellId);
40
- const activate = () => onCellClick?.({ col: columnLabel, row: rowLabel });
41
-
42
- return (
43
- <Rectangle
44
- key={cellId}
45
- ref={(instance) =>
46
- bindCellAccessibility(instance, `${columnLabel}${rowLabel}`, selected, activate)
47
- }
48
- bounds={bounds}
49
- pathOptions={{
50
- fillColor: selected ? "red" : "transparent",
51
- fillOpacity: selected ? 0.5 : 0,
52
- weight: 1.5,
53
- }}
54
- eventHandlers={{
55
- click: activate,
56
- }}
57
- />
58
- );
59
- }),
60
- );
61
-
62
- const columnHeaders = columnLabels.map((columnLabel, colCount) => (
63
- <Marker
64
- key={`col-${columnLabel}`}
65
- position={[height, (colLeft(colCount) + colRight(colCount)) / 2]}
66
- icon={headerIcon(columnLabel, "grid-header-label--column")}
67
- interactive={false}
68
- />
69
- ));
70
-
71
- const rowHeaders = Array.from({ length: rows }).map((_, rowCount) => {
72
- const rowLabel = rowOffset + (rows - rowCount);
73
- return (
74
- <Marker
75
- key={`row-${rowLabel}`}
76
- position={[(rowBottom(rowCount) + rowTop(rowCount)) / 2, -7]}
77
- icon={headerIcon(String(rowLabel), "grid-header-label--row")}
78
- interactive={false}
79
- />
80
- );
81
- });
82
-
83
- return [...cells, columnHeaders, rowHeaders];
84
- };
@@ -1,69 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { CRS, type LatLngBoundsExpression, type LatLngTuple } from "leaflet";
3
- import { MapContainer, ImageOverlay } from "react-leaflet";
4
- import type { ImageLayerProps } from "../types";
5
- import { Grid } from "./Grid";
6
- import { imageBounds } from "../helpers/imageBounds";
7
- import { colNames } from "../helpers/colNames";
8
- import { resolveWeights } from "../helpers/weights";
9
-
10
- export const ImageLayer = ({
11
- image,
12
- colOffset,
13
- rowOffset,
14
- width,
15
- height,
16
- alt,
17
- selectedCells,
18
- maxBoundsPadding = 100,
19
- onCellClick,
20
- }: ImageLayerProps) => {
21
- const [imgBounds, setImgBounds] = useState<LatLngTuple | null>(null);
22
- const [loadError, setLoadError] = useState<Error | null>(null);
23
-
24
- useEffect(() => {
25
- imageBounds(image.src, width, height).then(setImgBounds).catch(setLoadError);
26
- }, [image.src, width, height]);
27
-
28
- if (loadError) {
29
- throw loadError;
30
- }
31
-
32
- if (!imgBounds) {
33
- return null;
34
- }
35
-
36
- const [imgHeight, imgWidth] = imgBounds;
37
- const bounds: LatLngBoundsExpression = [[0, 0], imgBounds];
38
- const maxBounds: LatLngBoundsExpression = [
39
- [-maxBoundsPadding , -maxBoundsPadding],
40
- [imgHeight + maxBoundsPadding, imgWidth + maxBoundsPadding],
41
- ];
42
- const columnLabels = colNames(colOffset, image.columns);
43
- const columnWeights = resolveWeights(image.columns, image.columnWeights);
44
- const rowWeights = resolveWeights(image.rows, image.rowWeights);
45
-
46
- return (
47
- <MapContainer
48
- crs={CRS.Simple}
49
- bounds={bounds}
50
- boundsOptions={{ padding: [50, 50] }}
51
- maxBounds={maxBounds}
52
- maxBoundsViscosity={0.5}
53
- style={{ width, height }}
54
- maxZoom={2}
55
- >
56
- <Grid
57
- rows={image.rows}
58
- rowOffset={rowOffset}
59
- rowWeights={rowWeights}
60
- columnLabels={columnLabels}
61
- columnWeights={columnWeights}
62
- imgBounds={imgBounds}
63
- selectedCells={selectedCells}
64
- onCellClick={onCellClick}
65
- />
66
- <ImageOverlay url={image.src} bounds={bounds} alt={alt} />
67
- </MapContainer>
68
- );
69
- };
@@ -1,28 +0,0 @@
1
- import type { Rectangle as LeafletRectangle } from "leaflet";
2
-
3
- // Path.getElement() returns the underlying SVG node but isn't declared in @types/leaflet, hence the cast.
4
- const getPathElement = (instance: LeafletRectangle) =>
5
- (instance as unknown as { getElement(): SVGElement | undefined }).getElement();
6
-
7
- export const bindCellAccessibility = (
8
- instance: LeafletRectangle | null,
9
- label: string,
10
- selected: boolean,
11
- onActivate: () => void,
12
- ) => {
13
- const el = instance && getPathElement(instance);
14
- if (!el) {
15
- return;
16
- }
17
-
18
- el.setAttribute("role", "button");
19
- el.setAttribute("tabindex", "0");
20
- el.setAttribute("aria-label", label);
21
- el.setAttribute("aria-pressed", String(selected));
22
- el.onkeydown = (e) => {
23
- if (e.key === "Enter" || e.key === " ") {
24
- e.preventDefault();
25
- onActivate();
26
- }
27
- };
28
- };
@@ -1,20 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { colNames } from "./colNames";
3
-
4
- describe("colNames", () => {
5
- it("generates the first 26 letters starting from offset 0", () => {
6
- expect(colNames(0, 3)).toEqual(["A", "B", "C"]);
7
- });
8
-
9
- it("wraps into double letters after Z", () => {
10
- expect(colNames(24, 4)).toEqual(["Y", "Z", "AA", "AB"]);
11
- });
12
-
13
- it("continues from a non-zero offset, as used for multi-image column continuity", () => {
14
- expect(colNames(5, 3)).toEqual(["F", "G", "H"]);
15
- });
16
-
17
- it("returns an empty array for a zero count", () => {
18
- expect(colNames(0, 0)).toEqual([]);
19
- });
20
- });
@@ -1,15 +0,0 @@
1
- const toColumnLetter = (n: number): string => {
2
- let result = "";
3
- let remaining = n;
4
-
5
- while (remaining > 0) {
6
- const remainder = (remaining - 1) % 26;
7
- result = String.fromCharCode(65 + remainder) + result;
8
- remaining = Math.floor((remaining - 1) / 26);
9
- }
10
-
11
- return result;
12
- };
13
-
14
- export const colNames = (startIndex: number, count: number): string[] =>
15
- Array.from({ length: count }, (_, i) => toColumnLetter(startIndex + i + 1));
@@ -1,8 +0,0 @@
1
- import { divIcon } from "leaflet";
2
-
3
- export const headerIcon = (label: string, className: string) =>
4
- divIcon({
5
- html: `<span class="grid-header-label ${className}">${label}</span>`,
6
- className: "grid-header-label-icon",
7
- iconSize: [0, 0],
8
- });
@@ -1,62 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { imageBounds } from "./imageBounds";
3
-
4
- class FakeImage {
5
- width: number;
6
- height: number;
7
- shouldError: boolean;
8
- onload: (() => void) | null = null;
9
- onerror: (() => void) | null = null;
10
-
11
- constructor(width: number, height: number, shouldError: boolean) {
12
- this.width = width;
13
- this.height = height;
14
- this.shouldError = shouldError;
15
- }
16
-
17
- set src(_value: string) {
18
- queueMicrotask(() => {
19
- if (this.shouldError) {
20
- this.onerror?.();
21
- } else {
22
- this.onload?.();
23
- }
24
- });
25
- }
26
- }
27
-
28
- const stubImage = (width: number, height: number, shouldError = false) => {
29
- vi.stubGlobal(
30
- "Image",
31
- class {
32
- constructor() {
33
- return new FakeImage(width, height, shouldError);
34
- }
35
- },
36
- );
37
- };
38
-
39
- afterEach(() => {
40
- vi.unstubAllGlobals();
41
- });
42
-
43
- describe("imageBounds", () => {
44
- it("scales by the limiting dimension, preserving aspect ratio (width-constrained)", async () => {
45
- stubImage(400, 200);
46
- const bounds = await imageBounds("img.png", 100, 100);
47
- expect(bounds).toEqual([50, 100]);
48
- });
49
-
50
- it("scales by the limiting dimension, preserving aspect ratio (height-constrained)", async () => {
51
- stubImage(200, 400);
52
- const bounds = await imageBounds("img.png", 100, 100);
53
- expect(bounds).toEqual([100, 50]);
54
- });
55
-
56
- it("rejects when the image fails to load", async () => {
57
- stubImage(0, 0, true);
58
- await expect(imageBounds("missing.png", 100, 100)).rejects.toThrow(
59
- "Failed to load image: missing.png",
60
- );
61
- });
62
- });
@@ -1,12 +0,0 @@
1
- import type { LatLngTuple } from "leaflet";
2
-
3
- export const imageBounds = (src: string, containerWidth: number, containerHeight: number): Promise<LatLngTuple> =>
4
- new Promise((resolve, reject) => {
5
- const img = new Image();
6
- img.src = src;
7
- img.onload = () => {
8
- const scale = Math.min(containerWidth / img.width, containerHeight / img.height);
9
- resolve([img.height * scale, img.width * scale]);
10
- };
11
- img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
12
- });
@@ -1,22 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { resolveWeights, weightOffsets } from "./weights";
3
-
4
- describe("resolveWeights", () => {
5
- it("defaults every index to weight 1 with no overrides", () => {
6
- expect(resolveWeights(4)).toEqual([1, 1, 1, 1]);
7
- });
8
-
9
- it("applies overrides only to the specified indices", () => {
10
- expect(resolveWeights(4, { 2: 3 })).toEqual([1, 1, 3, 1]);
11
- });
12
- });
13
-
14
- describe("weightOffsets", () => {
15
- it("returns cumulative offsets starting at 0, ending at the total weight", () => {
16
- expect(weightOffsets([1, 1, 3, 1])).toEqual([0, 1, 2, 5, 6]);
17
- });
18
-
19
- it("returns [0] for an empty weight list", () => {
20
- expect(weightOffsets([])).toEqual([0]);
21
- });
22
- });
@@ -1,11 +0,0 @@
1
- export const resolveWeights = (
2
- count: number,
3
- overrides: Record<number, number> = {},
4
- ): number[] => Array.from({ length: count }, (_, i) => overrides[i] ?? 1);
5
-
6
- /** Cumulative leading edge (in weight units) of each index, plus the total at the end. */
7
- export const weightOffsets = (weights: number[]): number[] => {
8
- const offsets = [0];
9
- weights.forEach((weight) => offsets.push(offsets[offsets.length - 1] + weight));
10
- return offsets;
11
- };