mapped-image 0.1.1 → 0.2.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
 
@@ -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;
@@ -32,8 +31,7 @@ export type MappedImageProps = {
32
31
  /** Extra pannable margin (in pixels) beyond the image edges. Defaults to 50. */
33
32
  maxBoundsPadding?: number;
34
33
  };
35
-
36
- export type ImageLayerProps = {
34
+ type ImageLayerProps = {
37
35
  image: ImageConfig;
38
36
  colOffset: number;
39
37
  rowOffset: number;
@@ -44,8 +42,7 @@ export type ImageLayerProps = {
44
42
  maxBoundsPadding?: number;
45
43
  onCellClick: GridProps["onCellClick"];
46
44
  };
47
-
48
- export type GridProps = {
45
+ type GridProps = {
49
46
  rows: number;
50
47
  rowOffset: number;
51
48
  rowWeights: number[];
@@ -55,8 +52,11 @@ export type GridProps = {
55
52
  selectedCells: Set<string>;
56
53
  onCellClick?: (props: ICellClickProps) => void;
57
54
  };
58
-
59
- export interface ICellClickProps {
55
+ interface ICellClickProps {
60
56
  col: string;
61
57
  row: number;
62
58
  }
59
+
60
+ declare const MappedImage: (props: MappedImageProps) => react.JSX.Element;
61
+
62
+ export { type GridProps, type ICellClickProps, type ImageConfig, type ImageLayerProps, type ImageSelectorProps, MappedImage, type MappedImageProps };
@@ -0,0 +1,335 @@
1
+ // MappedImage.tsx
2
+ import { useState as useState2 } 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");
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, useState } from "react";
66
+ import { CRS } from "leaflet";
67
+ import { MapContainer, ImageOverlay } from "react-leaflet";
68
+
69
+ // components/Grid.tsx
70
+ import { Rectangle, Marker } from "react-leaflet";
71
+
72
+ // helpers/weights.ts
73
+ var resolveWeights = (count, overrides = {}) => Array.from({ length: count }, (_, i) => overrides[i] ?? 1);
74
+ var weightOffsets = (weights) => {
75
+ const offsets = [0];
76
+ weights.forEach((weight) => offsets.push(offsets[offsets.length - 1] + weight));
77
+ return offsets;
78
+ };
79
+
80
+ // helpers/headerIcon.ts
81
+ import { divIcon } from "leaflet";
82
+ var headerIcon = (label, className) => divIcon({
83
+ html: `<span class="grid-header-label ${className}">${label}</span>`,
84
+ className: "grid-header-label-icon",
85
+ iconSize: [0, 0]
86
+ });
87
+
88
+ // helpers/cellAccessibility.ts
89
+ var getPathElement = (instance) => instance.getElement();
90
+ var bindCellAccessibility = (instance, label, selected, onActivate) => {
91
+ const el = instance && getPathElement(instance);
92
+ if (!el) {
93
+ return;
94
+ }
95
+ el.setAttribute("role", "button");
96
+ el.setAttribute("tabindex", "0");
97
+ el.setAttribute("aria-label", label);
98
+ el.setAttribute("aria-pressed", String(selected));
99
+ el.onkeydown = (e) => {
100
+ if (e.key === "Enter" || e.key === " ") {
101
+ e.preventDefault();
102
+ onActivate();
103
+ }
104
+ };
105
+ };
106
+
107
+ // components/Grid.tsx
108
+ import { jsx as jsx2 } from "react/jsx-runtime";
109
+ var Grid = ({
110
+ rows,
111
+ rowOffset,
112
+ rowWeights,
113
+ columnLabels,
114
+ columnWeights,
115
+ imgBounds,
116
+ selectedCells,
117
+ onCellClick
118
+ }) => {
119
+ const [height, width] = imgBounds;
120
+ const colOffsets = weightOffsets(columnWeights);
121
+ const pxPerColWeightUnit = width / colOffsets[colOffsets.length - 1];
122
+ const colLeft = (colCount) => colOffsets[colCount] * pxPerColWeightUnit;
123
+ const colRight = (colCount) => colOffsets[colCount + 1] * pxPerColWeightUnit;
124
+ const rowOffsets = weightOffsets(rowWeights);
125
+ const pxPerRowWeightUnit = height / rowOffsets[rowOffsets.length - 1];
126
+ const rowBottom = (rowCount) => rowOffsets[rowCount] * pxPerRowWeightUnit;
127
+ const rowTop = (rowCount) => rowOffsets[rowCount + 1] * pxPerRowWeightUnit;
128
+ const cells = Array.from({ length: rows }).map(
129
+ (_, rowCount) => columnLabels.map((columnLabel, colCount) => {
130
+ const bounds = [
131
+ [rowTop(rowCount), colRight(colCount)],
132
+ [rowBottom(rowCount), colLeft(colCount)]
133
+ ];
134
+ const rowLabel = rowOffset + (rows - rowCount);
135
+ const cellId = `${rowLabel},${columnLabel}`;
136
+ const selected = selectedCells.has(cellId);
137
+ const activate = () => onCellClick?.({ col: columnLabel, row: rowLabel });
138
+ return /* @__PURE__ */ jsx2(
139
+ Rectangle,
140
+ {
141
+ ref: (instance) => bindCellAccessibility(instance, `${columnLabel}${rowLabel}`, selected, activate),
142
+ bounds,
143
+ pathOptions: {
144
+ fillColor: selected ? "red" : "transparent",
145
+ fillOpacity: selected ? 0.5 : 0,
146
+ weight: 1.5
147
+ },
148
+ eventHandlers: {
149
+ click: activate
150
+ }
151
+ },
152
+ cellId
153
+ );
154
+ })
155
+ );
156
+ const columnHeaders = columnLabels.map((columnLabel, colCount) => /* @__PURE__ */ jsx2(
157
+ Marker,
158
+ {
159
+ position: [height, (colLeft(colCount) + colRight(colCount)) / 2],
160
+ icon: headerIcon(columnLabel, "grid-header-label--column"),
161
+ interactive: false
162
+ },
163
+ `col-${columnLabel}`
164
+ ));
165
+ const rowHeaders = Array.from({ length: rows }).map((_, rowCount) => {
166
+ const rowLabel = rowOffset + (rows - rowCount);
167
+ return /* @__PURE__ */ jsx2(
168
+ Marker,
169
+ {
170
+ position: [(rowBottom(rowCount) + rowTop(rowCount)) / 2, -7],
171
+ icon: headerIcon(String(rowLabel), "grid-header-label--row"),
172
+ interactive: false
173
+ },
174
+ `row-${rowLabel}`
175
+ );
176
+ });
177
+ return [...cells, columnHeaders, rowHeaders];
178
+ };
179
+
180
+ // helpers/imageBounds.ts
181
+ var imageBounds = (src, containerWidth, containerHeight) => new Promise((resolve, reject) => {
182
+ const img = new Image();
183
+ img.src = src;
184
+ img.onload = () => {
185
+ const scale = Math.min(containerWidth / img.width, containerHeight / img.height);
186
+ resolve([img.height * scale, img.width * scale]);
187
+ };
188
+ img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
189
+ });
190
+
191
+ // helpers/colNames.ts
192
+ var toColumnLetter = (n) => {
193
+ let result = "";
194
+ let remaining = n;
195
+ while (remaining > 0) {
196
+ const remainder = (remaining - 1) % 26;
197
+ result = String.fromCharCode(65 + remainder) + result;
198
+ remaining = Math.floor((remaining - 1) / 26);
199
+ }
200
+ return result;
201
+ };
202
+ var colNames = (startIndex, count) => Array.from({ length: count }, (_, i) => toColumnLetter(startIndex + i + 1));
203
+
204
+ // components/ImageLayer.tsx
205
+ import { jsx as jsx3, jsxs } from "react/jsx-runtime";
206
+ var ImageLayer = ({
207
+ image,
208
+ colOffset,
209
+ rowOffset,
210
+ width,
211
+ height,
212
+ alt,
213
+ selectedCells,
214
+ maxBoundsPadding = 100,
215
+ onCellClick
216
+ }) => {
217
+ const [imgBounds, setImgBounds] = useState(null);
218
+ const [loadError, setLoadError] = useState(null);
219
+ useEffect(() => {
220
+ imageBounds(image.src, width, height).then(setImgBounds).catch(setLoadError);
221
+ }, [image.src, width, height]);
222
+ if (loadError) {
223
+ throw loadError;
224
+ }
225
+ if (!imgBounds) {
226
+ return null;
227
+ }
228
+ const [imgHeight, imgWidth] = imgBounds;
229
+ const bounds = [[0, 0], imgBounds];
230
+ const maxBounds = [
231
+ [-maxBoundsPadding, -maxBoundsPadding],
232
+ [imgHeight + maxBoundsPadding, imgWidth + maxBoundsPadding]
233
+ ];
234
+ const columnLabels = colNames(colOffset, image.columns);
235
+ const columnWeights = resolveWeights(image.columns, image.columnWeights);
236
+ const rowWeights = resolveWeights(image.rows, image.rowWeights);
237
+ return /* @__PURE__ */ jsxs(
238
+ MapContainer,
239
+ {
240
+ crs: CRS.Simple,
241
+ bounds,
242
+ boundsOptions: { padding: [50, 50] },
243
+ maxBounds,
244
+ maxBoundsViscosity: 0.5,
245
+ style: { width, height },
246
+ maxZoom: 2,
247
+ children: [
248
+ /* @__PURE__ */ jsx3(
249
+ Grid,
250
+ {
251
+ rows: image.rows,
252
+ rowOffset,
253
+ rowWeights,
254
+ columnLabels,
255
+ columnWeights,
256
+ imgBounds,
257
+ selectedCells,
258
+ onCellClick
259
+ }
260
+ ),
261
+ /* @__PURE__ */ jsx3(ImageOverlay, { url: image.src, bounds, alt })
262
+ ]
263
+ }
264
+ );
265
+ };
266
+
267
+ // MappedImage.tsx
268
+ import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
269
+ var MappedImage = (props) => {
270
+ if (props.images.length === 0) {
271
+ throw new Error("MappedImage requires at least one entry in `images`.");
272
+ }
273
+ const invalidImage = props.images.find((image) => image.rows <= 0 || image.columns <= 0);
274
+ if (invalidImage) {
275
+ throw new Error(
276
+ `MappedImage: image "${invalidImage.name}" must have rows > 0 and columns > 0.`
277
+ );
278
+ }
279
+ const [internalSelectedCells, setInternalSelectedCells] = useState2(
280
+ props.selectedCells ?? /* @__PURE__ */ new Set()
281
+ );
282
+ const isSelectionControlled = props.selectedCells !== void 0;
283
+ const selectedCells = isSelectionControlled ? props.selectedCells : internalSelectedCells;
284
+ const [selectedImageIndex, setSelectedImageIndex] = useState2(
285
+ props.selectedImageIndex ?? 0
286
+ );
287
+ const selectedImage = props.images[selectedImageIndex];
288
+ const colOffset = props.images.slice(0, selectedImageIndex).reduce((sum, image) => sum + image.columns, 0);
289
+ const rowOffset = props.images.slice(0, selectedImageIndex).reduce((sum, image) => sum + image.rows, 0);
290
+ const handleSelectImage = (index) => {
291
+ setSelectedImageIndex(index);
292
+ props.onSelectedImageIndexChange?.(index);
293
+ };
294
+ const handleCellClick = ({ row, col }) => {
295
+ const cellId = `${row},${col}`;
296
+ const next = new Set(selectedCells);
297
+ if (next.has(cellId)) {
298
+ next.delete(cellId);
299
+ } else {
300
+ next.add(cellId);
301
+ }
302
+ if (!isSelectionControlled) {
303
+ setInternalSelectedCells(next);
304
+ }
305
+ props.onSelectedCellsChange?.(next);
306
+ props.onCellClick?.({ row, col });
307
+ };
308
+ const renderSelector = props.renderImageSelector ?? ((selectorProps) => /* @__PURE__ */ jsx4(DefaultImageSelector, { ...selectorProps }));
309
+ return /* @__PURE__ */ jsxs2("div", { style: { position: "relative", width: props.width, height: props.height }, children: [
310
+ /* @__PURE__ */ jsx4(
311
+ ImageLayer,
312
+ {
313
+ image: selectedImage,
314
+ colOffset,
315
+ rowOffset,
316
+ width: props.width,
317
+ height: props.height,
318
+ alt: props.alt,
319
+ selectedCells,
320
+ maxBoundsPadding: props.maxBoundsPadding,
321
+ onCellClick: handleCellClick
322
+ },
323
+ selectedImage.src
324
+ ),
325
+ renderSelector({
326
+ images: props.images,
327
+ selectedIndex: selectedImageIndex,
328
+ onSelect: handleSelectImage
329
+ })
330
+ ] });
331
+ };
332
+ export {
333
+ MappedImage
334
+ };
335
+ //# 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","../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\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 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\")","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 { 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 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 <Grid\n rows={image.rows}\n rowOffset={rowOffset}\n rowWeights={rowWeights}\n columnLabels={columnLabels}\n columnWeights={columnWeights}\n imgBounds={imgBounds}\n selectedCells={selectedCells}\n onCellClick={onCellClick}\n />\n <ImageOverlay url={image.src} bounds={bounds} alt={alt} />\n </MapContainer>\n );\n};\n","import { Rectangle, Marker } from \"react-leaflet\";\nimport type { LatLngBoundsExpression } from \"leaflet\";\nimport type { GridProps } from \"../types\";\nimport { weightOffsets } from \"../helpers/weights\";\nimport { headerIcon } from \"../helpers/headerIcon\";\nimport { bindCellAccessibility } from \"../helpers/cellAccessibility\";\n\nexport const Grid = ({\n rows,\n rowOffset,\n rowWeights,\n columnLabels,\n columnWeights,\n imgBounds,\n selectedCells,\n onCellClick,\n}: GridProps) => {\n const [height, width] = imgBounds;\n\n const colOffsets = weightOffsets(columnWeights);\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(rowWeights);\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 return [...cells, columnHeaders, rowHeaders];\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","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 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":";AAAA,SAAS,YAAAA,iBAAgB;AACzB,OAAO;;;ACAkB,SAAR,YAA6B,KAAK,EAAE,SAAS,IAAI,CAAC,GAAG;AAC1D,MAAI,CAAC,OAAO,OAAO,aAAa,YAAa;AAE7C,QAAM,OAAO,SAAS,QAAQ,SAAS,qBAAqB,MAAM,EAAE,CAAC;AACrE,QAAM,QAAQ,SAAS,cAAc,OAAO;AAC5C,QAAM,OAAO;AAEb,MAAI,aAAa,OAAO;AACtB,QAAI,KAAK,YAAY;AACnB,WAAK,aAAa,OAAO,KAAK,UAAU;AAAA,IAC1C,OAAO;AACL,WAAK,YAAY,KAAK;AAAA,IACxB;AAAA,EACF,OAAO;AACL,SAAK,YAAY,KAAK;AAAA,EACxB;AAEA,MAAI,MAAM,YAAY;AACpB,UAAM,WAAW,UAAU;AAAA,EAC7B,OAAO;AACL,UAAM,YAAY,SAAS,eAAe,GAAG,CAAC;AAAA,EAChD;AACF;;;ACvB8B,YAAY,kUAAkU;;;ACc1W;AAZL,IAAM,uBAAuB,CAAC,EAAE,QAAQ,eAAe,SAAS,MACnE;AAAA,EAAC;AAAA;AAAA,IACG,OAAO;AAAA,MACH,UAAU;AAAA,MACV,KAAK;AAAA,MACL,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,KAAK;AAAA,IACT;AAAA,IAEC,iBAAO,IAAI,CAAC,OAAO,UAChB;AAAA,MAAC;AAAA;AAAA,QAEG,MAAK;AAAA,QACL,SAAS,MAAM,SAAS,KAAK;AAAA,QAC7B,OAAO;AAAA,UACH,SAAS;AAAA,UACT,UAAU;AAAA,UACV,YAAY,UAAU,gBAAgB,SAAS;AAAA,UAC/C,YAAY,UAAU,gBAAgB,YAAY;AAAA,UAClD,OAAO,UAAU,gBAAgB,UAAU;AAAA,UAC3C,QAAQ;AAAA,UACR,cAAc;AAAA,QAClB;AAAA,QAEC,gBAAM;AAAA;AAAA,MAbF,MAAM;AAAA,IAcf,CACH;AAAA;AACL;;;AC/BJ,SAAS,WAAW,gBAAgB;AACpC,SAAS,WAA0D;AACnE,SAAS,cAAc,oBAAoB;;;ACF3C,SAAS,WAAW,cAAc;;;ACA3B,IAAM,iBAAiB,CAC1B,OACA,YAAoC,CAAC,MAC1B,MAAM,KAAK,EAAE,QAAQ,MAAM,GAAG,CAAC,GAAG,MAAM,UAAU,CAAC,KAAK,CAAC;AAGjE,IAAM,gBAAgB,CAAC,YAAgC;AAC1D,QAAM,UAAU,CAAC,CAAC;AAClB,UAAQ,QAAQ,CAAC,WAAW,QAAQ,KAAK,QAAQ,QAAQ,SAAS,CAAC,IAAI,MAAM,CAAC;AAC9E,SAAO;AACX;;;ACVA,SAAS,eAAe;AAEjB,IAAM,aAAa,CAAC,OAAe,cACtC,QAAQ;AAAA,EACJ,MAAM,kCAAkC,SAAS,KAAK,KAAK;AAAA,EAC3D,WAAW;AAAA,EACX,UAAU,CAAC,GAAG,CAAC;AACnB,CAAC;;;ACJL,IAAM,iBAAiB,CAAC,aACnB,SAAiE,WAAW;AAE1E,IAAM,wBAAwB,CACjC,UACA,OACA,UACA,eACC;AACD,QAAM,KAAK,YAAY,eAAe,QAAQ;AAC9C,MAAI,CAAC,IAAI;AACL;AAAA,EACJ;AAEA,KAAG,aAAa,QAAQ,QAAQ;AAChC,KAAG,aAAa,YAAY,GAAG;AAC/B,KAAG,aAAa,cAAc,KAAK;AACnC,KAAG,aAAa,gBAAgB,OAAO,QAAQ,CAAC;AAChD,KAAG,YAAY,CAAC,MAAM;AAClB,QAAI,EAAE,QAAQ,WAAW,EAAE,QAAQ,KAAK;AACpC,QAAE,eAAe;AACjB,iBAAW;AAAA,IACf;AAAA,EACJ;AACJ;;;AHegB,gBAAAC,YAAA;AAnCT,IAAM,OAAO,CAAC;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ,MAAiB;AACb,QAAM,CAAC,QAAQ,KAAK,IAAI;AAExB,QAAM,aAAa,cAAc,aAAa;AAC9C,QAAM,qBAAqB,QAAQ,WAAW,WAAW,SAAS,CAAC;AACnE,QAAM,UAAU,CAAC,aAAqB,WAAW,QAAQ,IAAI;AAC7D,QAAM,WAAW,CAAC,aAAqB,WAAW,WAAW,CAAC,IAAI;AAElE,QAAM,aAAa,cAAc,UAAU;AAC3C,QAAM,qBAAqB,SAAS,WAAW,WAAW,SAAS,CAAC;AACpE,QAAM,YAAY,CAAC,aAAqB,WAAW,QAAQ,IAAI;AAC/D,QAAM,SAAS,CAAC,aAAqB,WAAW,WAAW,CAAC,IAAI;AAEhE,QAAM,QAAQ,MAAM,KAAK,EAAE,QAAQ,KAAK,CAAC,EAAE;AAAA,IAAI,CAAC,GAAG,aAC/C,aAAa,IAAI,CAAC,aAAa,aAAa;AACxC,YAAM,SAAiC;AAAA,QACnC,CAAC,OAAO,QAAQ,GAAG,SAAS,QAAQ,CAAC;AAAA,QACrC,CAAC,UAAU,QAAQ,GAAG,QAAQ,QAAQ,CAAC;AAAA,MAC3C;AAEA,YAAM,WAAW,aAAa,OAAO;AACrC,YAAM,SAAS,GAAG,QAAQ,IAAI,WAAW;AACzC,YAAM,WAAW,cAAc,IAAI,MAAM;AACzC,YAAM,WAAW,MAAM,cAAc,EAAE,KAAK,aAAa,KAAK,SAAS,CAAC;AAExE,aACI,gBAAAA;AAAA,QAAC;AAAA;AAAA,UAEG,KAAK,CAAC,aACF,sBAAsB,UAAU,GAAG,WAAW,GAAG,QAAQ,IAAI,UAAU,QAAQ;AAAA,UAEnF;AAAA,UACA,aAAa;AAAA,YACT,WAAW,WAAW,QAAQ;AAAA,YAC9B,aAAa,WAAW,MAAM;AAAA,YAC9B,QAAQ;AAAA,UACZ;AAAA,UACA,eAAe;AAAA,YACX,OAAO;AAAA,UACX;AAAA;AAAA,QAZK;AAAA,MAaT;AAAA,IAER,CAAC;AAAA,EACL;AAEA,QAAM,gBAAgB,aAAa,IAAI,CAAC,aAAa,aACjD,gBAAAA;AAAA,IAAC;AAAA;AAAA,MAEG,UAAU,CAAC,SAAS,QAAQ,QAAQ,IAAI,SAAS,QAAQ,KAAK,CAAC;AAAA,MAC/D,MAAM,WAAW,aAAa,2BAA2B;AAAA,MACzD,aAAa;AAAA;AAAA,IAHR,OAAO,WAAW;AAAA,EAI3B,CACH;AAED,QAAM,aAAa,MAAM,KAAK,EAAE,QAAQ,KAAK,CAAC,EAAE,IAAI,CAAC,GAAG,aAAa;AACjE,UAAM,WAAW,aAAa,OAAO;AACrC,WACI,gBAAAA;AAAA,MAAC;AAAA;AAAA,QAEG,UAAU,EAAE,UAAU,QAAQ,IAAI,OAAO,QAAQ,KAAK,GAAG,EAAE;AAAA,QAC3D,MAAM,WAAW,OAAO,QAAQ,GAAG,wBAAwB;AAAA,QAC3D,aAAa;AAAA;AAAA,MAHR,OAAO,QAAQ;AAAA,IAIxB;AAAA,EAER,CAAC;AAED,SAAO,CAAC,GAAG,OAAO,eAAe,UAAU;AAC/C;;;AIjFO,IAAM,cAAc,CAAC,KAAa,gBAAwB,oBAC7D,IAAI,QAAQ,CAAC,SAAS,WAAW;AAC7B,QAAM,MAAM,IAAI,MAAM;AACtB,MAAI,MAAM;AACV,MAAI,SAAS,MAAM;AACf,UAAM,QAAQ,KAAK,IAAI,iBAAiB,IAAI,OAAO,kBAAkB,IAAI,MAAM;AAC/E,YAAQ,CAAC,IAAI,SAAS,OAAO,IAAI,QAAQ,KAAK,CAAC;AAAA,EACnD;AACA,MAAI,UAAU,MAAM,OAAO,IAAI,MAAM,yBAAyB,GAAG,EAAE,CAAC;AACxE,CAAC;;;ACXL,IAAM,iBAAiB,CAAC,MAAsB;AAC1C,MAAI,SAAS;AACb,MAAI,YAAY;AAEhB,SAAO,YAAY,GAAG;AAClB,UAAM,aAAa,YAAY,KAAK;AACpC,aAAS,OAAO,aAAa,KAAK,SAAS,IAAI;AAC/C,gBAAY,KAAK,OAAO,YAAY,KAAK,EAAE;AAAA,EAC/C;AAEA,SAAO;AACX;AAEO,IAAM,WAAW,CAAC,YAAoB,UACzC,MAAM,KAAK,EAAE,QAAQ,MAAM,GAAG,CAAC,GAAG,MAAM,eAAe,aAAa,IAAI,CAAC,CAAC;;;ANgCtE,SASI,OAAAC,MATJ;AArCD,IAAM,aAAa,CAAC;AAAA,EACvB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,mBAAmB;AAAA,EACnB;AACJ,MAAuB;AACnB,QAAM,CAAC,WAAW,YAAY,IAAI,SAA6B,IAAI;AACnE,QAAM,CAAC,WAAW,YAAY,IAAI,SAAuB,IAAI;AAE7D,YAAU,MAAM;AACZ,gBAAY,MAAM,KAAK,OAAO,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY;AAAA,EAC/E,GAAG,CAAC,MAAM,KAAK,OAAO,MAAM,CAAC;AAE7B,MAAI,WAAW;AACX,UAAM;AAAA,EACV;AAEA,MAAI,CAAC,WAAW;AACZ,WAAO;AAAA,EACX;AAEA,QAAM,CAAC,WAAW,QAAQ,IAAI;AAC9B,QAAM,SAAiC,CAAC,CAAC,GAAG,CAAC,GAAG,SAAS;AACzD,QAAM,YAAoC;AAAA,IACtC,CAAC,CAAC,kBAAmB,CAAC,gBAAgB;AAAA,IACtC,CAAC,YAAY,kBAAkB,WAAW,gBAAgB;AAAA,EAC9D;AACA,QAAM,eAAe,SAAS,WAAW,MAAM,OAAO;AACtD,QAAM,gBAAgB,eAAe,MAAM,SAAS,MAAM,aAAa;AACvE,QAAM,aAAa,eAAe,MAAM,MAAM,MAAM,UAAU;AAE9D,SACI;AAAA,IAAC;AAAA;AAAA,MACG,KAAK,IAAI;AAAA,MACT;AAAA,MACA,eAAe,EAAE,SAAS,CAAC,IAAI,EAAE,EAAE;AAAA,MACnC;AAAA,MACA,oBAAoB;AAAA,MACpB,OAAO,EAAE,OAAO,OAAO;AAAA,MACvB,SAAS;AAAA,MAET;AAAA,wBAAAA;AAAA,UAAC;AAAA;AAAA,YACG,MAAM,MAAM;AAAA,YACZ;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA;AAAA,QACJ;AAAA,QACA,gBAAAA,KAAC,gBAAa,KAAK,MAAM,KAAK,QAAgB,KAAU;AAAA;AAAA;AAAA,EAC5D;AAER;;;AJP+C,gBAAAC,MAIvC,QAAAC,aAJuC;AApDxC,IAAM,cAAc,CAAC,UAA4B;AACpD,MAAI,MAAM,OAAO,WAAW,GAAG;AAC3B,UAAM,IAAI,MAAM,sDAAsD;AAAA,EAC1E;AAEA,QAAM,eAAe,MAAM,OAAO,KAAK,CAAC,UAAU,MAAM,QAAQ,KAAK,MAAM,WAAW,CAAC;AACvF,MAAI,cAAc;AACd,UAAM,IAAI;AAAA,MACN,uBAAuB,aAAa,IAAI;AAAA,IAC5C;AAAA,EACJ;AAEA,QAAM,CAAC,uBAAuB,wBAAwB,IAAIC;AAAA,IACtD,MAAM,iBAAiB,oBAAI,IAAI;AAAA,EACnC;AACA,QAAM,wBAAwB,MAAM,kBAAkB;AACtD,QAAM,gBAAgB,wBAAwB,MAAM,gBAAiB;AAErE,QAAM,CAAC,oBAAoB,qBAAqB,IAAIA;AAAA,IAChD,MAAM,sBAAsB;AAAA,EAChC;AAEA,QAAM,gBAAgB,MAAM,OAAO,kBAAkB;AACrD,QAAM,YAAY,MAAM,OACnB,MAAM,GAAG,kBAAkB,EAC3B,OAAO,CAAC,KAAK,UAAU,MAAM,MAAM,SAAS,CAAC;AAClD,QAAM,YAAY,MAAM,OACnB,MAAM,GAAG,kBAAkB,EAC3B,OAAO,CAAC,KAAK,UAAU,MAAM,MAAM,MAAM,CAAC;AAE/C,QAAM,oBAAoB,CAAC,UAAkB;AACzC,0BAAsB,KAAK;AAC3B,UAAM,6BAA6B,KAAK;AAAA,EAC5C;AAEA,QAAM,kBAAkB,CAAC,EAAE,KAAK,IAAI,MAAuB;AACvD,UAAM,SAAS,GAAG,GAAG,IAAI,GAAG;AAC5B,UAAM,OAAO,IAAI,IAAI,aAAa;AAClC,QAAI,KAAK,IAAI,MAAM,GAAG;AAClB,WAAK,OAAO,MAAM;AAAA,IACtB,OAAO;AACH,WAAK,IAAI,MAAM;AAAA,IACnB;AAEA,QAAI,CAAC,uBAAuB;AACxB,+BAAyB,IAAI;AAAA,IACjC;AACA,UAAM,wBAAwB,IAAI;AAClC,UAAM,cAAc,EAAE,KAAK,IAAI,CAAC;AAAA,EACpC;AAEA,QAAM,iBAAiB,MAAM,wBACzB,CAAC,kBAAsC,gBAAAF,KAAC,wBAAsB,GAAG,eAAe;AAGpF,SACI,gBAAAC,MAAC,SAAI,OAAO,EAAE,UAAU,YAAY,OAAO,MAAM,OAAO,QAAQ,MAAM,OAAO,GACzE;AAAA,oBAAAD;AAAA,MAAC;AAAA;AAAA,QAEG,OAAO;AAAA,QACP;AAAA,QACA;AAAA,QACA,OAAO,MAAM;AAAA,QACb,QAAQ,MAAM;AAAA,QACd,KAAK,MAAM;AAAA,QACX;AAAA,QACA,kBAAkB,MAAM;AAAA,QACxB,aAAa;AAAA;AAAA,MATR,cAAc;AAAA,IAUvB;AAAA,IACC,eAAe;AAAA,MACZ,QAAQ,MAAM;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,IACd,CAAC;AAAA,KACL;AAER;","names":["useState","jsx","jsx","jsx","jsxs","useState"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mapped-image",
3
- "version": "0.1.1",
3
+ "version": "0.2.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,22 +21,32 @@
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"
34
43
  },
35
44
  "dependencies": {
36
45
  "leaflet": "^1.9.4",
37
- "react-leaflet": "^5.0.0"
46
+ "react-leaflet": "^5.0.0",
47
+ "@types/leaflet": "^1.9.21"
38
48
  },
39
49
  "devDependencies": {
40
- "@types/leaflet": "^1.9.21"
50
+ "tsup": "^8.3.5"
41
51
  }
42
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
- };