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