@broberg/bodymap 0.1.1 → 0.1.3

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/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useState, useRef } from 'react';
2
2
  import { z } from 'zod';
3
3
  import { jsxs, jsx } from 'react/jsx-runtime';
4
4
 
@@ -39,6 +39,9 @@ var REGIONS = [
39
39
  ];
40
40
  var REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));
41
41
  REGIONS.map((r) => r.key);
42
+ function getRegion(key) {
43
+ return REGIONS.find((r) => r.key === key);
44
+ }
42
45
  var PAIN_TYPES = ["stikkende", "dump", "konstant", "jagende"];
43
46
  var painPointSchema = z.object({
44
47
  region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: "unknown region" }),
@@ -55,6 +58,18 @@ function isSelectable(key, config = {}) {
55
58
  if (s?.visible === false) return false;
56
59
  return s?.selectable ?? true;
57
60
  }
61
+ var defaultPalette = {
62
+ body: "#d2d7de",
63
+ hover: "#8fd0cd",
64
+ selected: "#5cc4b7",
65
+ heat: { low: "#fcd34d", mid: "#fb923c", high: "#ef4444" }
66
+ };
67
+ function heatFor(intensity, palette = defaultPalette) {
68
+ return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;
69
+ }
70
+ function baseColorFor(regionKey, palette = defaultPalette) {
71
+ return palette.regions?.[regionKey] ?? palette.body;
72
+ }
58
73
  z.object({
59
74
  schema: z.literal("bodymap/v1"),
60
75
  view: z.enum(["front", "back", "left", "right"]),
@@ -165,48 +180,162 @@ var FILL_BACK = {
165
180
  hip_left: "#c99bd6",
166
181
  hip_right: "#c99bd6"
167
182
  };
183
+ var daRegions = Object.fromEntries(REGIONS.map((r) => [r.key, r.label]));
184
+ var EN_CODE = {
185
+ HEAD: "Head",
186
+ NECK: "Neck",
187
+ CHEST: "Chest",
188
+ THORA: "Upper back",
189
+ LUMBAR: "Lower back",
190
+ GROIN: "Groin",
191
+ SHOULDER: "Shoulder",
192
+ UARM: "Upper arm",
193
+ ELBOW: "Elbow",
194
+ FARM: "Forearm",
195
+ WRIST: "Wrist",
196
+ HAND: "Hand",
197
+ HIP: "Hip",
198
+ THIGH: "Thigh",
199
+ KNEE: "Knee",
200
+ LOWLEG: "Lower leg",
201
+ ANKLE: "Ankle",
202
+ FOOT: "Foot"
203
+ };
204
+ var enRegions = Object.fromEntries(
205
+ REGIONS.map((r) => [r.key, EN_CODE[r.code] + (r.side ? r.side === "left" ? ", left" : ", right" : "")])
206
+ );
207
+ var LABELS_DA = {
208
+ regions: daRegions,
209
+ qualities: { stikkende: "stikkende", dump: "dump", konstant: "konstant", jagende: "jagende" },
210
+ intensity: "Intensitet (0-10)",
211
+ quality: "Kvalitet",
212
+ remove: "Fjern punkt",
213
+ front: "Forfra",
214
+ back: "Bagfra",
215
+ empty: "V\xE6lg en kropsdel for at markere smerte.",
216
+ ariaMarked: (n, i, q) => `${n}, smerte ${i} af 10${q ? ", " + q : ""}`,
217
+ ariaUnmarked: (n) => `${n}, ikke markeret. Aktiv\xE9r for at markere smerte.`,
218
+ svgLabel: "Kropskort \u2014 v\xE6lg hvor det g\xF8r ondt",
219
+ viewLabel: "Visning",
220
+ zoomLabel: "Zoom",
221
+ zoomIn: "Zoom ind",
222
+ zoomOut: "Zoom ud",
223
+ zoomReset: "Nulstil zoom"
224
+ };
225
+ var LABELS_EN = {
226
+ regions: enRegions,
227
+ qualities: { stikkende: "stabbing", dump: "dull", konstant: "constant", jagende: "shooting" },
228
+ intensity: "Intensity (0-10)",
229
+ quality: "Quality",
230
+ remove: "Remove point",
231
+ front: "Front",
232
+ back: "Back",
233
+ empty: "Pick a body part to mark pain.",
234
+ ariaMarked: (n, i, q) => `${n}, pain ${i} of 10${q ? ", " + q : ""}`,
235
+ ariaUnmarked: (n) => `${n}, not marked. Activate to mark pain.`,
236
+ svgLabel: "Body map \u2014 pick where it hurts",
237
+ viewLabel: "View",
238
+ zoomLabel: "Zoom",
239
+ zoomIn: "Zoom in",
240
+ zoomOut: "Zoom out",
241
+ zoomReset: "Reset zoom"
242
+ };
243
+ function resolveLabels(locale, overrides) {
244
+ const base = locale === "en" ? LABELS_EN : LABELS_DA;
245
+ if (!overrides) return base;
246
+ return {
247
+ ...base,
248
+ ...overrides,
249
+ regions: { ...base.regions, ...overrides.regions },
250
+ qualities: { ...base.qualities, ...overrides.qualities }
251
+ };
252
+ }
253
+ var VBW = 260;
254
+ var VBH = 470;
255
+ var MIN_HIT = 18;
168
256
  function center(s) {
169
257
  if (s.el === "rect") return { x: s.x + s.width / 2, y: s.y + s.height / 2 };
170
258
  return { x: s.cx, y: s.cy };
171
259
  }
260
+ function hitShape(s) {
261
+ if (s.el === "circle") return { el: "circle", cx: s.cx, cy: s.cy, r: Math.max(s.r, MIN_HIT) };
262
+ if (s.el === "ellipse")
263
+ return { el: "ellipse", cx: s.cx, cy: s.cy, rx: Math.max(s.rx, MIN_HIT), ry: Math.max(s.ry, MIN_HIT) };
264
+ const c = center(s);
265
+ const w = Math.max(s.width, MIN_HIT * 2);
266
+ const h = Math.max(s.height, MIN_HIT * 2);
267
+ return { el: "rect", x: c.x - w / 2, y: c.y - h / 2, width: w, height: h, rx: s.rx };
268
+ }
172
269
  function heat(v) {
173
270
  return v >= 7 ? "#ef4444" : v >= 4 ? "#fb923c" : "#fcd34d";
174
271
  }
272
+ function heatTier(v) {
273
+ return v >= 7 ? "high" : v >= 4 ? "mid" : "low";
274
+ }
275
+ function baseFill(key, rainbow, palette) {
276
+ return palette ? baseColorFor(key, palette) : `var(--bmap-region-${key}, var(--bmap-body, ${rainbow}))`;
277
+ }
278
+ function markedFill(v, palette) {
279
+ return palette ? heatFor(v, palette) : `var(--bmap-heat-${heatTier(v)}, ${heat(v)})`;
280
+ }
281
+ function shapeEl(s, props) {
282
+ if (s.el === "circle") return /* @__PURE__ */ jsx("circle", { cx: s.cx, cy: s.cy, r: s.r, ...props });
283
+ if (s.el === "ellipse") return /* @__PURE__ */ jsx("ellipse", { cx: s.cx, cy: s.cy, rx: s.rx, ry: s.ry, ...props });
284
+ return /* @__PURE__ */ jsx("rect", { x: s.x, y: s.y, width: s.width, height: s.height, rx: s.rx, ...props });
285
+ }
175
286
  var STYLE_ID = "broberg-bodymap-styles";
176
287
  var STYLE = `
177
288
  .bmap{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-panel:#fff;--bmap-ink:#1e293b;--bmap-muted:#64748b;
178
289
  display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}
179
- .bmap__svg{flex:0 0 auto}
180
- .bmap__stage{display:flex;flex-direction:column;gap:12px;flex:0 0 auto}
181
- .bmap__viewtoggle{display:inline-flex;align-self:flex-start;background:#f1f5f9;border:1px solid var(--bmap-line);border-radius:10px;padding:3px;gap:2px}
182
- .bmap__vbtn{font:inherit;font-size:13px;font-weight:600;color:var(--bmap-muted);background:none;border:0;border-radius:7px;padding:6px 16px;cursor:pointer;transition:color .12s,background .12s,transform .1s}
290
+ .bmap__stage{display:flex;flex-direction:column;gap:10px;flex:0 1 340px;min-width:0;max-width:360px}
291
+ .bmap__bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
292
+ .bmap__viewtoggle{display:inline-flex;background:#f1f5f9;border:1px solid var(--bmap-line);border-radius:10px;padding:3px;gap:2px}
293
+ .bmap__vbtn{font:inherit;font-size:13px;font-weight:600;color:var(--bmap-muted);background:none;border:0;border-radius:7px;padding:8px 16px;cursor:pointer;transition:color .12s,background .12s,transform .1s}
183
294
  .bmap__vbtn:hover{color:var(--bmap-ink)}
184
295
  .bmap__vbtn:active{transform:scale(.97)}
185
296
  .bmap__vbtn--on{background:#fff;color:var(--bmap-ink);box-shadow:0 1px 2px rgba(15,23,42,.14)}
186
- .bmap__region{cursor:pointer;stroke:#fff;stroke-width:2.4;transition:filter .12s}
187
- .bmap__region:hover{filter:brightness(1.08) saturate(1.15)}
188
- .bmap__region--locked{cursor:default;opacity:.4}
189
- .bmap__region--sel{stroke:var(--bmap-accent);stroke-width:3.6}
190
- .bmap__heatn{font:700 11px ui-sans-serif;fill:#fff;pointer-events:none}
191
- .bmap__panel{flex:1 1 220px;min-width:220px;border:1px solid var(--bmap-line);border-radius:14px;padding:16px 18px;background:var(--bmap-panel)}
297
+ .bmap__zoom{display:inline-flex;gap:4px;margin-left:auto}
298
+ .bmap__zoom button{font:inherit;font-weight:700;font-size:16px;line-height:1;width:36px;height:36px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);border-radius:8px;cursor:pointer;transition:border-color .12s,transform .1s;display:flex;align-items:center;justify-content:center}
299
+ .bmap__zoom button:hover{border-color:var(--bmap-accent)}
300
+ .bmap__zoom button:active{transform:scale(.9)}
301
+ .bmap__zoom button:disabled{opacity:.4;cursor:default}
302
+ .bmap__svg{width:100%;height:auto;display:block;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;background:transparent;border-radius:14px}
303
+ .bmap__vis{stroke:#fff;stroke-width:2.4;pointer-events:none;transition:filter .12s,stroke .12s,stroke-width .12s}
304
+ .bmap__vis--sel{stroke:var(--bmap-accent);stroke-width:3.6}
305
+ .bmap__hit{fill:transparent;cursor:pointer;outline:none}
306
+ .bmap__hit--locked{cursor:default}
307
+ .bmap__hit:hover + .bmap__vis{filter:brightness(1.08) saturate(1.15)}
308
+ .bmap__hit:focus-visible + .bmap__vis{stroke:var(--bmap-accent);stroke-width:4;stroke-dasharray:4 2.5}
309
+ .bmap__heatn{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}
310
+ .bmap__panel{flex:1 1 240px;min-width:220px;border:1px solid var(--bmap-line);border-radius:14px;padding:16px 18px;background:var(--bmap-panel)}
192
311
  .bmap__panel--empty{color:var(--bmap-muted);font-size:13.5px}
193
312
  .bmap__ph{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}
194
313
  .bmap__ph b{font-size:16px}
195
314
  .bmap__code{font:11px ui-monospace,monospace;color:var(--bmap-muted);background:#f1f5f9;border-radius:6px;padding:2px 7px}
196
315
  .bmap__lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bmap-muted);margin:0 0 7px}
197
- .bmap__scale{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:14px}
198
- .bmap__i{font:inherit;font-size:13px;font-weight:600;width:30px;height:30px;border-radius:8px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);cursor:pointer;transition:.1s}
316
+ .bmap__scale{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
317
+ .bmap__i{font:inherit;font-size:14px;font-weight:600;width:38px;height:38px;border-radius:9px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);cursor:pointer;transition:border-color .1s,background .1s,transform .1s}
199
318
  .bmap__i:hover{border-color:var(--bmap-accent)}
200
319
  .bmap__i:active{transform:scale(.9)}
201
320
  .bmap__i--on{background:var(--bmap-accent);border-color:var(--bmap-accent);color:#fff}
202
- .bmap__chips{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
203
- .bmap__chip{font:inherit;font-size:12.5px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-muted);border-radius:999px;padding:6px 12px;cursor:pointer;transition:.1s}
321
+ .bmap__chips{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}
322
+ .bmap__chip{font:inherit;font-size:13px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-muted);border-radius:999px;padding:9px 15px;cursor:pointer;transition:.1s}
204
323
  .bmap__chip:hover{border-color:var(--bmap-accent);color:var(--bmap-accent)}
205
324
  .bmap__chip:active{transform:scale(.96)}
206
325
  .bmap__chip--on{background:var(--bmap-ink);border-color:var(--bmap-ink);color:#fff}
207
- .bmap__rm{font:inherit;font-size:13px;font-weight:600;border:1px solid #f6c9c9;background:#fff;color:#ef4444;border-radius:9px;padding:8px 13px;cursor:pointer;transition:.1s}
326
+ .bmap__rm{font:inherit;font-size:14px;font-weight:600;border:1px solid #f6c9c9;background:#fff;color:#ef4444;border-radius:9px;padding:10px 15px;cursor:pointer;transition:.1s}
208
327
  .bmap__rm:hover{background:#fef2f2}
209
328
  .bmap__rm:active{transform:scale(.97)}
329
+ .bmap__i:focus-visible,.bmap__chip:focus-visible,.bmap__vbtn:focus-visible,.bmap__rm:focus-visible,.bmap__zoom button:focus-visible{outline:2px solid var(--bmap-accent);outline-offset:2px}
330
+ @media (max-width:560px){
331
+ .bmap{flex-direction:column;gap:14px}
332
+ .bmap__stage{max-width:100%;width:100%;flex-basis:auto}
333
+ .bmap__panel{width:100%}
334
+ .bmap__i{width:46px;height:46px;font-size:16px}
335
+ .bmap__chip{padding:11px 17px;font-size:15px}
336
+ .bmap__vbtn{padding:10px 18px;font-size:15px}
337
+ }
338
+ @media (prefers-reduced-motion:reduce){.bmap *{transition:none !important}}
210
339
  `;
211
340
  function ensureStyles() {
212
341
  if (typeof document === "undefined") return;
@@ -216,6 +345,12 @@ function ensureStyles() {
216
345
  el.textContent = STYLE;
217
346
  document.head.appendChild(el);
218
347
  }
348
+ var ZMIN = 1;
349
+ var ZMAX = 4;
350
+ var clampPan = (p, z2) => ({
351
+ x: Math.min(0, Math.max(VBW * (1 - z2), p.x)),
352
+ y: Math.min(0, Math.max(VBH * (1 - z2), p.y))
353
+ });
219
354
  function BodyMap({
220
355
  value,
221
356
  defaultValue,
@@ -223,15 +358,93 @@ function BodyMap({
223
358
  config = {},
224
359
  defaultView = "front",
225
360
  onViewChange,
361
+ palette,
362
+ locale = "da",
363
+ labels,
364
+ readOnly = false,
226
365
  className
227
366
  }) {
228
367
  useEffect(ensureStyles, []);
229
368
  const [internal, setInternal] = useState(defaultValue ?? []);
230
369
  const [selected, setSelected] = useState(null);
231
370
  const [view, setView] = useState(defaultView);
371
+ const [zoom, setZoom] = useState(1);
372
+ const [pan, setPan] = useState({ x: 0, y: 0 });
232
373
  const report = value ?? internal;
233
374
  const shapes = view === "back" ? SHAPES_BACK : SHAPES;
234
375
  const fills = view === "back" ? FILL_BACK : FILL;
376
+ const L = resolveLabels(locale, labels);
377
+ const nameOf = (key) => L.regions[key] ?? getRegion(key)?.label ?? key;
378
+ const svgRef = useRef(null);
379
+ const zoomRef = useRef(1);
380
+ const panRef = useRef({ x: 0, y: 0 });
381
+ const pointers = useRef(/* @__PURE__ */ new Map());
382
+ const gesture = useRef({ startDist: 0, startZoom: 1, midVB: { x: 0, y: 0 }, start: { x: 0, y: 0 }, moved: false });
383
+ const suppressClick = useRef(false);
384
+ const setZP = (z2, p) => {
385
+ const cz = Math.max(ZMIN, Math.min(ZMAX, z2));
386
+ const cp = clampPan(p, cz);
387
+ zoomRef.current = cz;
388
+ panRef.current = cp;
389
+ setZoom(cz);
390
+ setPan(cp);
391
+ };
392
+ const screenToVB = (cx, cy) => {
393
+ const el = svgRef.current;
394
+ if (!el) return { x: VBW / 2, y: VBH / 2 };
395
+ const r = el.getBoundingClientRect();
396
+ if (!r.width || !r.height) return { x: VBW / 2, y: VBH / 2 };
397
+ return { x: (cx - r.left) / r.width * VBW, y: (cy - r.top) / r.height * VBH };
398
+ };
399
+ const zoomTo = (nz, focalVB) => {
400
+ const z2 = Math.max(ZMIN, Math.min(ZMAX, nz));
401
+ const p = panRef.current;
402
+ setZP(z2, { x: focalVB.x - (focalVB.x - p.x) * (z2 / zoomRef.current), y: focalVB.y - (focalVB.y - p.y) * (z2 / zoomRef.current) });
403
+ };
404
+ const zoomBtn = (factor) => zoomTo(zoomRef.current * factor, { x: VBW / 2, y: VBH / 2 });
405
+ const resetZoom = () => setZP(1, { x: 0, y: 0 });
406
+ const onPointerDown = (e) => {
407
+ pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
408
+ const pts = [...pointers.current.values()];
409
+ if (pts.length === 2) {
410
+ gesture.current.startDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
411
+ gesture.current.startZoom = zoomRef.current;
412
+ gesture.current.midVB = screenToVB((pts[0].x + pts[1].x) / 2, (pts[0].y + pts[1].y) / 2);
413
+ gesture.current.moved = true;
414
+ suppressClick.current = true;
415
+ } else {
416
+ gesture.current.start = { x: e.clientX, y: e.clientY };
417
+ gesture.current.moved = false;
418
+ }
419
+ };
420
+ const onPointerMove = (e) => {
421
+ const prev = pointers.current.get(e.pointerId);
422
+ if (!prev) return;
423
+ pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
424
+ const pts = [...pointers.current.values()];
425
+ if (pts.length >= 2) {
426
+ const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
427
+ if (gesture.current.startDist > 0) zoomTo(gesture.current.startZoom * (dist / gesture.current.startDist), gesture.current.midVB);
428
+ return;
429
+ }
430
+ const el = svgRef.current;
431
+ const rect = el?.getBoundingClientRect();
432
+ if (Math.hypot(e.clientX - gesture.current.start.x, e.clientY - gesture.current.start.y) > 6) {
433
+ gesture.current.moved = true;
434
+ suppressClick.current = true;
435
+ }
436
+ if (zoomRef.current > 1 && rect && rect.width) {
437
+ const dxVB = (e.clientX - prev.x) / rect.width * VBW;
438
+ const dyVB = (e.clientY - prev.y) / rect.height * VBH;
439
+ setZP(zoomRef.current, { x: panRef.current.x + dxVB, y: panRef.current.y + dyVB });
440
+ }
441
+ };
442
+ const onPointerUp = (e) => {
443
+ pointers.current.delete(e.pointerId);
444
+ if (pointers.current.size === 0) window.setTimeout(() => {
445
+ suppressClick.current = false;
446
+ }, 60);
447
+ };
235
448
  const changeView = (v) => {
236
449
  if (v === view) return;
237
450
  setView(v);
@@ -252,68 +465,96 @@ function BodyMap({
252
465
  commit(report.filter((p) => p.region !== key));
253
466
  setSelected(null);
254
467
  };
468
+ const pickRegion = (key) => {
469
+ if (suppressClick.current) {
470
+ suppressClick.current = false;
471
+ return;
472
+ }
473
+ setSelected(key);
474
+ };
255
475
  const regions = resolveRegions(config);
256
476
  const region = selected ? REGIONS.find((r) => r.key === selected) : void 0;
257
477
  const current = selected ? pointOf(selected) : void 0;
258
478
  const rootCls = ["bmap", className].filter(Boolean).join(" ");
259
- return /* @__PURE__ */ jsxs("div", { className: rootCls, "data-testid": "bodymap-root", children: [
479
+ const rootStyle = palette ? { "--bmap-accent": palette.selected } : void 0;
480
+ const ariaFor = (r, marked, interactive = true) => {
481
+ if (marked) return L.ariaMarked(nameOf(r.key), marked.intensity, marked.type ? L.qualities[marked.type] : void 0);
482
+ return interactive ? L.ariaUnmarked(nameOf(r.key)) : nameOf(r.key);
483
+ };
484
+ return /* @__PURE__ */ jsxs("div", { className: rootCls, "data-testid": "bodymap-root", style: rootStyle, children: [
260
485
  /* @__PURE__ */ jsxs("div", { className: "bmap__stage", children: [
261
- /* @__PURE__ */ jsxs("div", { className: "bmap__viewtoggle", role: "group", "aria-label": "Visning", children: [
262
- /* @__PURE__ */ jsx(
263
- "button",
264
- {
265
- type: "button",
266
- className: "bmap__vbtn" + (view === "front" ? " bmap__vbtn--on" : ""),
267
- "data-testid": "bodymap-view-front",
268
- "aria-pressed": view === "front",
269
- onClick: () => changeView("front"),
270
- children: "Forfra"
271
- }
272
- ),
273
- /* @__PURE__ */ jsx(
274
- "button",
275
- {
276
- type: "button",
277
- className: "bmap__vbtn" + (view === "back" ? " bmap__vbtn--on" : ""),
278
- "data-testid": "bodymap-view-back",
279
- "aria-pressed": view === "back",
280
- onClick: () => changeView("back"),
281
- children: "Bagfra"
282
- }
283
- )
486
+ /* @__PURE__ */ jsxs("div", { className: "bmap__bar", children: [
487
+ /* @__PURE__ */ jsxs("div", { className: "bmap__viewtoggle", role: "group", "aria-label": L.viewLabel, children: [
488
+ /* @__PURE__ */ jsx(
489
+ "button",
490
+ {
491
+ type: "button",
492
+ className: "bmap__vbtn" + (view === "front" ? " bmap__vbtn--on" : ""),
493
+ "data-testid": "bodymap-view-front",
494
+ "aria-pressed": view === "front",
495
+ onClick: () => changeView("front"),
496
+ children: L.front
497
+ }
498
+ ),
499
+ /* @__PURE__ */ jsx(
500
+ "button",
501
+ {
502
+ type: "button",
503
+ className: "bmap__vbtn" + (view === "back" ? " bmap__vbtn--on" : ""),
504
+ "data-testid": "bodymap-view-back",
505
+ "aria-pressed": view === "back",
506
+ onClick: () => changeView("back"),
507
+ children: L.back
508
+ }
509
+ )
510
+ ] }),
511
+ /* @__PURE__ */ jsxs("div", { className: "bmap__zoom", role: "group", "aria-label": L.zoomLabel, children: [
512
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "bodymap-zoom-out", "aria-label": L.zoomOut, disabled: zoom <= ZMIN, onClick: () => zoomBtn(1 / 1.4), children: "\u2212" }),
513
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "bodymap-zoom-reset", "aria-label": L.zoomReset, disabled: zoom === 1 && pan.x === 0 && pan.y === 0, onClick: resetZoom, children: "\u2922" }),
514
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "bodymap-zoom-in", "aria-label": L.zoomIn, disabled: zoom >= ZMAX, onClick: () => zoomBtn(1.4), children: "+" })
515
+ ] })
284
516
  ] }),
285
- /* @__PURE__ */ jsxs(
517
+ /* @__PURE__ */ jsx(
286
518
  "svg",
287
519
  {
520
+ ref: svgRef,
288
521
  className: "bmap__svg",
289
522
  viewBox: "0 0 260 470",
290
- width: 230,
291
- height: 416,
292
523
  role: "group",
293
- "aria-label": "Kropskort \u2014 v\xE6lg hvor det g\xF8r ondt",
294
- children: [
524
+ "aria-label": L.svgLabel,
525
+ onPointerDown,
526
+ onPointerMove,
527
+ onPointerUp,
528
+ onPointerCancel: onPointerUp,
529
+ onWheel: (e) => zoomTo(zoomRef.current * (e.deltaY < 0 ? 1.12 : 1 / 1.12), screenToVB(e.clientX, e.clientY)),
530
+ children: /* @__PURE__ */ jsxs("g", { transform: `translate(${pan.x} ${pan.y}) scale(${zoom})`, children: [
295
531
  regions.map((r) => {
296
532
  const s = shapes[r.key];
297
533
  if (!s) return null;
298
534
  const marked = pointOf(r.key);
299
- const selectable = isSelectable(r.key, config);
300
- const cls = [
301
- "bmap__region",
302
- !selectable && "bmap__region--locked",
303
- selected === r.key && "bmap__region--sel"
304
- ].filter(Boolean).join(" ");
305
- const fill = marked ? heat(marked.intensity) : fills[r.key] ?? "#cbd5e1";
306
- const common = {
307
- className: cls,
308
- fill,
309
- "data-testid": `bodymap-region-${r.key}`,
310
- role: selectable ? "button" : void 0,
311
- "aria-label": r.label,
312
- onClick: selectable ? () => setSelected(r.key) : void 0
313
- };
314
- if (s.el === "circle") return /* @__PURE__ */ jsx("circle", { cx: s.cx, cy: s.cy, r: s.r, ...common }, r.key);
315
- if (s.el === "ellipse") return /* @__PURE__ */ jsx("ellipse", { cx: s.cx, cy: s.cy, rx: s.rx, ry: s.ry, ...common }, r.key);
316
- return /* @__PURE__ */ jsx("rect", { x: s.x, y: s.y, width: s.width, height: s.height, rx: s.rx, ...common }, r.key);
535
+ const interactive = isSelectable(r.key, config) && !readOnly;
536
+ const visCls = ["bmap__vis", selected === r.key && "bmap__vis--sel"].filter(Boolean).join(" ");
537
+ const fill = marked ? markedFill(marked.intensity, palette) : baseFill(r.key, fills[r.key] ?? "#cbd5e1", palette);
538
+ const hs = hitShape(s);
539
+ return /* @__PURE__ */ jsxs("g", { children: [
540
+ shapeEl(hs, {
541
+ className: "bmap__hit" + (interactive ? "" : " bmap__hit--locked"),
542
+ "data-testid": `bodymap-region-${r.key}`,
543
+ role: interactive ? "button" : "img",
544
+ tabIndex: interactive ? 0 : -1,
545
+ "aria-label": ariaFor(r, marked, interactive),
546
+ "aria-pressed": interactive ? selected === r.key : void 0,
547
+ onClick: interactive ? () => pickRegion(r.key) : void 0,
548
+ onKeyDown: interactive ? (e) => {
549
+ if (e.key === "Enter" || e.key === " ") {
550
+ e.preventDefault();
551
+ suppressClick.current = false;
552
+ setSelected(r.key);
553
+ }
554
+ } : void 0
555
+ }),
556
+ shapeEl(s, { className: visCls, fill, opacity: !readOnly && !isSelectable(r.key, config) ? 0.4 : 1 })
557
+ ] }, r.key);
317
558
  }),
318
559
  report.map((p) => {
319
560
  const s = shapes[p.region];
@@ -321,17 +562,17 @@ function BodyMap({
321
562
  const c = center(s);
322
563
  return /* @__PURE__ */ jsx("text", { x: c.x, y: c.y + 4, textAnchor: "middle", className: "bmap__heatn", children: p.intensity }, p.region);
323
564
  })
324
- ]
565
+ ] })
325
566
  }
326
567
  )
327
568
  ] }),
328
- region ? /* @__PURE__ */ jsxs("div", { className: "bmap__panel", "data-testid": "bodymap-panel", children: [
569
+ readOnly ? null : region ? /* @__PURE__ */ jsxs("div", { className: "bmap__panel", "data-testid": "bodymap-panel", children: [
329
570
  /* @__PURE__ */ jsxs("div", { className: "bmap__ph", children: [
330
- /* @__PURE__ */ jsx("b", { children: region.label }),
571
+ /* @__PURE__ */ jsx("b", { children: nameOf(region.key) }),
331
572
  /* @__PURE__ */ jsx("span", { className: "bmap__code", children: region.code })
332
573
  ] }),
333
- /* @__PURE__ */ jsx("p", { className: "bmap__lbl", children: "Intensitet (0-10)" }),
334
- /* @__PURE__ */ jsx("div", { className: "bmap__scale", role: "group", "aria-label": "Intensitet", children: Array.from({ length: 11 }, (_, n) => /* @__PURE__ */ jsx(
574
+ /* @__PURE__ */ jsx("p", { className: "bmap__lbl", id: "bmap-intensity-lbl", children: L.intensity }),
575
+ /* @__PURE__ */ jsx("div", { className: "bmap__scale", role: "group", "aria-labelledby": "bmap-intensity-lbl", children: Array.from({ length: 11 }, (_, n) => /* @__PURE__ */ jsx(
335
576
  "button",
336
577
  {
337
578
  type: "button",
@@ -343,8 +584,8 @@ function BodyMap({
343
584
  },
344
585
  n
345
586
  )) }),
346
- /* @__PURE__ */ jsx("p", { className: "bmap__lbl", children: "Kvalitet" }),
347
- /* @__PURE__ */ jsx("div", { className: "bmap__chips", role: "group", "aria-label": "Kvalitet", children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsx(
587
+ /* @__PURE__ */ jsx("p", { className: "bmap__lbl", id: "bmap-quality-lbl", children: L.quality }),
588
+ /* @__PURE__ */ jsx("div", { className: "bmap__chips", role: "group", "aria-labelledby": "bmap-quality-lbl", children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsx(
348
589
  "button",
349
590
  {
350
591
  type: "button",
@@ -352,15 +593,15 @@ function BodyMap({
352
593
  "data-testid": `bodymap-type-${t}`,
353
594
  "aria-pressed": current?.type === t,
354
595
  onClick: () => setPain(region.key, current?.intensity ?? 5, t),
355
- children: t
596
+ children: L.qualities[t]
356
597
  },
357
598
  t
358
599
  )) }),
359
- current && /* @__PURE__ */ jsx("button", { type: "button", className: "bmap__rm", "data-testid": "bodymap-remove", onClick: () => removePain(region.key), children: "Fjern punkt" })
360
- ] }) : /* @__PURE__ */ jsx("div", { className: "bmap__panel bmap__panel--empty", "data-testid": "bodymap-panel", children: "Klik en kropsdel for at markere smerte." })
600
+ current && /* @__PURE__ */ jsx("button", { type: "button", className: "bmap__rm", "data-testid": "bodymap-remove", onClick: () => removePain(region.key), children: L.remove })
601
+ ] }) : /* @__PURE__ */ jsx("div", { className: "bmap__panel bmap__panel--empty", "data-testid": "bodymap-panel", children: L.empty })
361
602
  ] });
362
603
  }
363
604
 
364
- export { BodyMap };
605
+ export { BodyMap, LABELS_DA, LABELS_EN };
365
606
  //# sourceMappingURL=react.js.map
366
607
  //# sourceMappingURL=react.js.map