@broberg/bodymap 0.1.0 → 0.1.2

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
 
@@ -126,42 +126,122 @@ var FILL = {
126
126
  foot_left: "#f0a5a5",
127
127
  foot_right: "#f0a5a5"
128
128
  };
129
+ var SHAPES_BACK = {
130
+ head: { el: "circle", cx: 130, cy: 40, r: 26 },
131
+ neck: { el: "rect", x: 120, y: 62, width: 20, height: 16, rx: 6 },
132
+ thora: { el: "rect", x: 100, y: 84, width: 60, height: 58, rx: 15 },
133
+ lumbar: { el: "rect", x: 104, y: 144, width: 52, height: 44, rx: 13 },
134
+ hip_left: { el: "rect", x: 100, y: 190, width: 27, height: 32, rx: 13 },
135
+ hip_right: { el: "rect", x: 133, y: 190, width: 27, height: 32, rx: 13 },
136
+ // arms — shared with the front (back of the arm sits at the same x/y)
137
+ shoulder_left: { el: "circle", cx: 88, cy: 98, r: 14 },
138
+ uarm_left: { el: "rect", x: 66, y: 104, width: 18, height: 54, rx: 9 },
139
+ elbow_left: { el: "circle", cx: 73, cy: 164, r: 10 },
140
+ farm_left: { el: "rect", x: 62, y: 174, width: 16, height: 50, rx: 8 },
141
+ wrist_left: { el: "circle", cx: 68, cy: 230, r: 8 },
142
+ hand_left: { el: "ellipse", cx: 67, cy: 250, rx: 11, ry: 13 },
143
+ shoulder_right: { el: "circle", cx: 172, cy: 98, r: 14 },
144
+ uarm_right: { el: "rect", x: 176, y: 104, width: 18, height: 54, rx: 9 },
145
+ elbow_right: { el: "circle", cx: 187, cy: 164, r: 10 },
146
+ farm_right: { el: "rect", x: 182, y: 174, width: 16, height: 50, rx: 8 },
147
+ wrist_right: { el: "circle", cx: 192, cy: 230, r: 8 },
148
+ hand_right: { el: "ellipse", cx: 193, cy: 250, rx: 11, ry: 13 },
149
+ // legs — back (thigh→calf→heel, same x/y as the front)
150
+ thigh_left: { el: "rect", x: 104, y: 224, width: 22, height: 80, rx: 11 },
151
+ thigh_right: { el: "rect", x: 134, y: 224, width: 22, height: 80, rx: 11 },
152
+ knee_left: { el: "circle", cx: 115, cy: 312, r: 13 },
153
+ knee_right: { el: "circle", cx: 145, cy: 312, r: 13 },
154
+ lowleg_left: { el: "rect", x: 106, y: 322, width: 18, height: 74, rx: 9 },
155
+ lowleg_right: { el: "rect", x: 136, y: 322, width: 18, height: 74, rx: 9 },
156
+ ankle_left: { el: "circle", cx: 115, cy: 404, r: 9 },
157
+ ankle_right: { el: "circle", cx: 145, cy: 404, r: 9 },
158
+ foot_left: { el: "ellipse", cx: 115, cy: 424, rx: 14, ry: 10 },
159
+ foot_right: { el: "ellipse", cx: 145, cy: 424, rx: 14, ry: 10 }
160
+ };
161
+ var FILL_BACK = {
162
+ ...FILL,
163
+ thora: "#e2896d",
164
+ lumbar: "#d97fa0",
165
+ hip_left: "#c99bd6",
166
+ hip_right: "#c99bd6"
167
+ };
168
+ var VBW = 260;
169
+ var VBH = 470;
170
+ var MIN_HIT = 18;
129
171
  function center(s) {
130
172
  if (s.el === "rect") return { x: s.x + s.width / 2, y: s.y + s.height / 2 };
131
173
  return { x: s.cx, y: s.cy };
132
174
  }
175
+ function hitShape(s) {
176
+ if (s.el === "circle") return { el: "circle", cx: s.cx, cy: s.cy, r: Math.max(s.r, MIN_HIT) };
177
+ if (s.el === "ellipse")
178
+ return { el: "ellipse", cx: s.cx, cy: s.cy, rx: Math.max(s.rx, MIN_HIT), ry: Math.max(s.ry, MIN_HIT) };
179
+ const c = center(s);
180
+ const w = Math.max(s.width, MIN_HIT * 2);
181
+ const h = Math.max(s.height, MIN_HIT * 2);
182
+ return { el: "rect", x: c.x - w / 2, y: c.y - h / 2, width: w, height: h, rx: s.rx };
183
+ }
133
184
  function heat(v) {
134
185
  return v >= 7 ? "#ef4444" : v >= 4 ? "#fb923c" : "#fcd34d";
135
186
  }
187
+ function shapeEl(s, props) {
188
+ if (s.el === "circle") return /* @__PURE__ */ jsx("circle", { cx: s.cx, cy: s.cy, r: s.r, ...props });
189
+ if (s.el === "ellipse") return /* @__PURE__ */ jsx("ellipse", { cx: s.cx, cy: s.cy, rx: s.rx, ry: s.ry, ...props });
190
+ return /* @__PURE__ */ jsx("rect", { x: s.x, y: s.y, width: s.width, height: s.height, rx: s.rx, ...props });
191
+ }
136
192
  var STYLE_ID = "broberg-bodymap-styles";
137
193
  var STYLE = `
138
194
  .bmap{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-panel:#fff;--bmap-ink:#1e293b;--bmap-muted:#64748b;
139
195
  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)}
140
- .bmap__svg{flex:0 0 auto}
141
- .bmap__region{cursor:pointer;stroke:#fff;stroke-width:2.4;transition:filter .12s}
142
- .bmap__region:hover{filter:brightness(1.08) saturate(1.15)}
143
- .bmap__region--locked{cursor:default;opacity:.4}
144
- .bmap__region--sel{stroke:var(--bmap-accent);stroke-width:3.6}
145
- .bmap__heatn{font:700 11px ui-sans-serif;fill:#fff;pointer-events:none}
146
- .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)}
196
+ .bmap__stage{display:flex;flex-direction:column;gap:10px;flex:0 1 340px;min-width:0;max-width:360px}
197
+ .bmap__bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
198
+ .bmap__viewtoggle{display:inline-flex;background:#f1f5f9;border:1px solid var(--bmap-line);border-radius:10px;padding:3px;gap:2px}
199
+ .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}
200
+ .bmap__vbtn:hover{color:var(--bmap-ink)}
201
+ .bmap__vbtn:active{transform:scale(.97)}
202
+ .bmap__vbtn--on{background:#fff;color:var(--bmap-ink);box-shadow:0 1px 2px rgba(15,23,42,.14)}
203
+ .bmap__zoom{display:inline-flex;gap:4px;margin-left:auto}
204
+ .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}
205
+ .bmap__zoom button:hover{border-color:var(--bmap-accent)}
206
+ .bmap__zoom button:active{transform:scale(.9)}
207
+ .bmap__zoom button:disabled{opacity:.4;cursor:default}
208
+ .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}
209
+ .bmap__vis{stroke:#fff;stroke-width:2.4;pointer-events:none;transition:filter .12s,stroke .12s,stroke-width .12s}
210
+ .bmap__vis--sel{stroke:var(--bmap-accent);stroke-width:3.6}
211
+ .bmap__hit{fill:transparent;cursor:pointer;outline:none}
212
+ .bmap__hit--locked{cursor:default}
213
+ .bmap__hit:hover + .bmap__vis{filter:brightness(1.08) saturate(1.15)}
214
+ .bmap__hit:focus-visible + .bmap__vis{stroke:var(--bmap-accent);stroke-width:4;stroke-dasharray:4 2.5}
215
+ .bmap__heatn{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}
216
+ .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)}
147
217
  .bmap__panel--empty{color:var(--bmap-muted);font-size:13.5px}
148
218
  .bmap__ph{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}
149
219
  .bmap__ph b{font-size:16px}
150
220
  .bmap__code{font:11px ui-monospace,monospace;color:var(--bmap-muted);background:#f1f5f9;border-radius:6px;padding:2px 7px}
151
221
  .bmap__lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bmap-muted);margin:0 0 7px}
152
- .bmap__scale{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:14px}
153
- .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}
222
+ .bmap__scale{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
223
+ .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}
154
224
  .bmap__i:hover{border-color:var(--bmap-accent)}
155
225
  .bmap__i:active{transform:scale(.9)}
156
226
  .bmap__i--on{background:var(--bmap-accent);border-color:var(--bmap-accent);color:#fff}
157
- .bmap__chips{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
158
- .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}
227
+ .bmap__chips{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}
228
+ .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}
159
229
  .bmap__chip:hover{border-color:var(--bmap-accent);color:var(--bmap-accent)}
160
230
  .bmap__chip:active{transform:scale(.96)}
161
231
  .bmap__chip--on{background:var(--bmap-ink);border-color:var(--bmap-ink);color:#fff}
162
- .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}
232
+ .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}
163
233
  .bmap__rm:hover{background:#fef2f2}
164
234
  .bmap__rm:active{transform:scale(.97)}
235
+ .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}
236
+ @media (max-width:560px){
237
+ .bmap{flex-direction:column;gap:14px}
238
+ .bmap__stage{max-width:100%;width:100%;flex-basis:auto}
239
+ .bmap__panel{width:100%}
240
+ .bmap__i{width:46px;height:46px;font-size:16px}
241
+ .bmap__chip{padding:11px 17px;font-size:15px}
242
+ .bmap__vbtn{padding:10px 18px;font-size:15px}
243
+ }
244
+ @media (prefers-reduced-motion:reduce){.bmap *{transition:none !important}}
165
245
  `;
166
246
  function ensureStyles() {
167
247
  if (typeof document === "undefined") return;
@@ -171,11 +251,107 @@ function ensureStyles() {
171
251
  el.textContent = STYLE;
172
252
  document.head.appendChild(el);
173
253
  }
174
- function BodyMap({ value, defaultValue, onChange, config = {}, className }) {
254
+ var ZMIN = 1;
255
+ var ZMAX = 4;
256
+ var clampPan = (p, z2) => ({
257
+ x: Math.min(0, Math.max(VBW * (1 - z2), p.x)),
258
+ y: Math.min(0, Math.max(VBH * (1 - z2), p.y))
259
+ });
260
+ function BodyMap({
261
+ value,
262
+ defaultValue,
263
+ onChange,
264
+ config = {},
265
+ defaultView = "front",
266
+ onViewChange,
267
+ className
268
+ }) {
175
269
  useEffect(ensureStyles, []);
176
270
  const [internal, setInternal] = useState(defaultValue ?? []);
177
271
  const [selected, setSelected] = useState(null);
272
+ const [view, setView] = useState(defaultView);
273
+ const [zoom, setZoom] = useState(1);
274
+ const [pan, setPan] = useState({ x: 0, y: 0 });
178
275
  const report = value ?? internal;
276
+ const shapes = view === "back" ? SHAPES_BACK : SHAPES;
277
+ const fills = view === "back" ? FILL_BACK : FILL;
278
+ const svgRef = useRef(null);
279
+ const zoomRef = useRef(1);
280
+ const panRef = useRef({ x: 0, y: 0 });
281
+ const pointers = useRef(/* @__PURE__ */ new Map());
282
+ const gesture = useRef({ startDist: 0, startZoom: 1, midVB: { x: 0, y: 0 }, start: { x: 0, y: 0 }, moved: false });
283
+ const suppressClick = useRef(false);
284
+ const setZP = (z2, p) => {
285
+ const cz = Math.max(ZMIN, Math.min(ZMAX, z2));
286
+ const cp = clampPan(p, cz);
287
+ zoomRef.current = cz;
288
+ panRef.current = cp;
289
+ setZoom(cz);
290
+ setPan(cp);
291
+ };
292
+ const screenToVB = (cx, cy) => {
293
+ const el = svgRef.current;
294
+ if (!el) return { x: VBW / 2, y: VBH / 2 };
295
+ const r = el.getBoundingClientRect();
296
+ if (!r.width || !r.height) return { x: VBW / 2, y: VBH / 2 };
297
+ return { x: (cx - r.left) / r.width * VBW, y: (cy - r.top) / r.height * VBH };
298
+ };
299
+ const zoomTo = (nz, focalVB) => {
300
+ const z2 = Math.max(ZMIN, Math.min(ZMAX, nz));
301
+ const p = panRef.current;
302
+ setZP(z2, { x: focalVB.x - (focalVB.x - p.x) * (z2 / zoomRef.current), y: focalVB.y - (focalVB.y - p.y) * (z2 / zoomRef.current) });
303
+ };
304
+ const zoomBtn = (factor) => zoomTo(zoomRef.current * factor, { x: VBW / 2, y: VBH / 2 });
305
+ const resetZoom = () => setZP(1, { x: 0, y: 0 });
306
+ const onPointerDown = (e) => {
307
+ pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
308
+ const pts = [...pointers.current.values()];
309
+ if (pts.length === 2) {
310
+ gesture.current.startDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
311
+ gesture.current.startZoom = zoomRef.current;
312
+ gesture.current.midVB = screenToVB((pts[0].x + pts[1].x) / 2, (pts[0].y + pts[1].y) / 2);
313
+ gesture.current.moved = true;
314
+ suppressClick.current = true;
315
+ } else {
316
+ gesture.current.start = { x: e.clientX, y: e.clientY };
317
+ gesture.current.moved = false;
318
+ }
319
+ };
320
+ const onPointerMove = (e) => {
321
+ const prev = pointers.current.get(e.pointerId);
322
+ if (!prev) return;
323
+ pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
324
+ const pts = [...pointers.current.values()];
325
+ if (pts.length >= 2) {
326
+ const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
327
+ if (gesture.current.startDist > 0) zoomTo(gesture.current.startZoom * (dist / gesture.current.startDist), gesture.current.midVB);
328
+ return;
329
+ }
330
+ const el = svgRef.current;
331
+ const rect = el?.getBoundingClientRect();
332
+ if (Math.hypot(e.clientX - gesture.current.start.x, e.clientY - gesture.current.start.y) > 6) {
333
+ gesture.current.moved = true;
334
+ suppressClick.current = true;
335
+ }
336
+ if (zoomRef.current > 1 && rect && rect.width) {
337
+ const dxVB = (e.clientX - prev.x) / rect.width * VBW;
338
+ const dyVB = (e.clientY - prev.y) / rect.height * VBH;
339
+ setZP(zoomRef.current, { x: panRef.current.x + dxVB, y: panRef.current.y + dyVB });
340
+ }
341
+ };
342
+ const onPointerUp = (e) => {
343
+ pointers.current.delete(e.pointerId);
344
+ if (pointers.current.size === 0) window.setTimeout(() => {
345
+ suppressClick.current = false;
346
+ }, 60);
347
+ };
348
+ const changeView = (v) => {
349
+ if (v === view) return;
350
+ setView(v);
351
+ const sh = v === "back" ? SHAPES_BACK : SHAPES;
352
+ if (selected && !sh[selected]) setSelected(null);
353
+ onViewChange?.(v);
354
+ };
179
355
  const commit = (next) => {
180
356
  if (value === void 0) setInternal(next);
181
357
  onChange?.(next);
@@ -189,73 +365,128 @@ function BodyMap({ value, defaultValue, onChange, config = {}, className }) {
189
365
  commit(report.filter((p) => p.region !== key));
190
366
  setSelected(null);
191
367
  };
368
+ const pickRegion = (key) => {
369
+ if (suppressClick.current) {
370
+ suppressClick.current = false;
371
+ return;
372
+ }
373
+ setSelected(key);
374
+ };
192
375
  const regions = resolveRegions(config);
193
376
  const region = selected ? REGIONS.find((r) => r.key === selected) : void 0;
194
377
  const current = selected ? pointOf(selected) : void 0;
195
378
  const rootCls = ["bmap", className].filter(Boolean).join(" ");
379
+ const ariaFor = (r, marked, selectable = true) => {
380
+ if (marked) return `${r.label}, smerte ${marked.intensity} af 10${marked.type ? ", " + marked.type : ""}`;
381
+ return selectable ? `${r.label}, ikke markeret. Aktiv\xE9r for at markere smerte.` : r.label;
382
+ };
196
383
  return /* @__PURE__ */ jsxs("div", { className: rootCls, "data-testid": "bodymap-root", children: [
197
- /* @__PURE__ */ jsxs(
198
- "svg",
199
- {
200
- className: "bmap__svg",
201
- viewBox: "0 0 260 470",
202
- width: 230,
203
- height: 416,
204
- role: "group",
205
- "aria-label": "Kropskort \u2014 v\xE6lg hvor det g\xF8r ondt",
206
- children: [
207
- regions.map((r) => {
208
- const s = SHAPES[r.key];
209
- if (!s) return null;
210
- const marked = pointOf(r.key);
211
- const selectable = isSelectable(r.key, config);
212
- const cls = [
213
- "bmap__region",
214
- !selectable && "bmap__region--locked",
215
- selected === r.key && "bmap__region--sel"
216
- ].filter(Boolean).join(" ");
217
- const fill = marked ? heat(marked.intensity) : FILL[r.key] ?? "#cbd5e1";
218
- const common = {
219
- className: cls,
220
- fill,
221
- "data-testid": `bodymap-region-${r.key}`,
222
- role: selectable ? "button" : void 0,
223
- "aria-label": r.label,
224
- onClick: selectable ? () => setSelected(r.key) : void 0
225
- };
226
- if (s.el === "circle") return /* @__PURE__ */ jsx("circle", { cx: s.cx, cy: s.cy, r: s.r, ...common }, r.key);
227
- if (s.el === "ellipse") return /* @__PURE__ */ jsx("ellipse", { cx: s.cx, cy: s.cy, rx: s.rx, ry: s.ry, ...common }, r.key);
228
- return /* @__PURE__ */ jsx("rect", { x: s.x, y: s.y, width: s.width, height: s.height, rx: s.rx, ...common }, r.key);
229
- }),
230
- report.map((p) => {
231
- const s = SHAPES[p.region];
232
- if (!s || config[p.region]?.visible === false) return null;
233
- const c = center(s);
234
- return /* @__PURE__ */ jsx("text", { x: c.x, y: c.y + 4, textAnchor: "middle", className: "bmap__heatn", children: p.intensity }, p.region);
235
- })
236
- ]
237
- }
238
- ),
384
+ /* @__PURE__ */ jsxs("div", { className: "bmap__stage", children: [
385
+ /* @__PURE__ */ jsxs("div", { className: "bmap__bar", children: [
386
+ /* @__PURE__ */ jsxs("div", { className: "bmap__viewtoggle", role: "group", "aria-label": "Visning", children: [
387
+ /* @__PURE__ */ jsx(
388
+ "button",
389
+ {
390
+ type: "button",
391
+ className: "bmap__vbtn" + (view === "front" ? " bmap__vbtn--on" : ""),
392
+ "data-testid": "bodymap-view-front",
393
+ "aria-pressed": view === "front",
394
+ onClick: () => changeView("front"),
395
+ children: "Forfra"
396
+ }
397
+ ),
398
+ /* @__PURE__ */ jsx(
399
+ "button",
400
+ {
401
+ type: "button",
402
+ className: "bmap__vbtn" + (view === "back" ? " bmap__vbtn--on" : ""),
403
+ "data-testid": "bodymap-view-back",
404
+ "aria-pressed": view === "back",
405
+ onClick: () => changeView("back"),
406
+ children: "Bagfra"
407
+ }
408
+ )
409
+ ] }),
410
+ /* @__PURE__ */ jsxs("div", { className: "bmap__zoom", role: "group", "aria-label": "Zoom", children: [
411
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "bodymap-zoom-out", "aria-label": "Zoom ud", disabled: zoom <= ZMIN, onClick: () => zoomBtn(1 / 1.4), children: "\u2212" }),
412
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "bodymap-zoom-reset", "aria-label": "Nulstil zoom", disabled: zoom === 1 && pan.x === 0 && pan.y === 0, onClick: resetZoom, children: "\u2922" }),
413
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "bodymap-zoom-in", "aria-label": "Zoom ind", disabled: zoom >= ZMAX, onClick: () => zoomBtn(1.4), children: "+" })
414
+ ] })
415
+ ] }),
416
+ /* @__PURE__ */ jsx(
417
+ "svg",
418
+ {
419
+ ref: svgRef,
420
+ className: "bmap__svg",
421
+ viewBox: "0 0 260 470",
422
+ role: "group",
423
+ "aria-label": "Kropskort \u2014 v\xE6lg hvor det g\xF8r ondt",
424
+ onPointerDown,
425
+ onPointerMove,
426
+ onPointerUp,
427
+ onPointerCancel: onPointerUp,
428
+ onWheel: (e) => zoomTo(zoomRef.current * (e.deltaY < 0 ? 1.12 : 1 / 1.12), screenToVB(e.clientX, e.clientY)),
429
+ children: /* @__PURE__ */ jsxs("g", { transform: `translate(${pan.x} ${pan.y}) scale(${zoom})`, children: [
430
+ regions.map((r) => {
431
+ const s = shapes[r.key];
432
+ if (!s) return null;
433
+ const marked = pointOf(r.key);
434
+ const selectable = isSelectable(r.key, config);
435
+ const visCls = ["bmap__vis", selected === r.key && "bmap__vis--sel"].filter(Boolean).join(" ");
436
+ const fill = marked ? heat(marked.intensity) : fills[r.key] ?? "#cbd5e1";
437
+ const hs = hitShape(s);
438
+ const act = () => selectable ? pickRegion(r.key) : void 0;
439
+ return /* @__PURE__ */ jsxs("g", { children: [
440
+ shapeEl(hs, {
441
+ className: "bmap__hit" + (selectable ? "" : " bmap__hit--locked"),
442
+ "data-testid": `bodymap-region-${r.key}`,
443
+ role: selectable ? "button" : "img",
444
+ tabIndex: selectable ? 0 : -1,
445
+ "aria-label": ariaFor(r, marked, selectable),
446
+ "aria-pressed": selectable ? selected === r.key : void 0,
447
+ onClick: act,
448
+ onKeyDown: selectable ? (e) => {
449
+ if (e.key === "Enter" || e.key === " ") {
450
+ e.preventDefault();
451
+ suppressClick.current = false;
452
+ setSelected(r.key);
453
+ }
454
+ } : void 0
455
+ }),
456
+ shapeEl(s, { className: visCls, fill, opacity: selectable ? 1 : 0.4 })
457
+ ] }, r.key);
458
+ }),
459
+ report.map((p) => {
460
+ const s = shapes[p.region];
461
+ if (!s || config[p.region]?.visible === false) return null;
462
+ const c = center(s);
463
+ return /* @__PURE__ */ jsx("text", { x: c.x, y: c.y + 4, textAnchor: "middle", className: "bmap__heatn", children: p.intensity }, p.region);
464
+ })
465
+ ] })
466
+ }
467
+ )
468
+ ] }),
239
469
  region ? /* @__PURE__ */ jsxs("div", { className: "bmap__panel", "data-testid": "bodymap-panel", children: [
240
470
  /* @__PURE__ */ jsxs("div", { className: "bmap__ph", children: [
241
471
  /* @__PURE__ */ jsx("b", { children: region.label }),
242
472
  /* @__PURE__ */ jsx("span", { className: "bmap__code", children: region.code })
243
473
  ] }),
244
- /* @__PURE__ */ jsx("p", { className: "bmap__lbl", children: "Intensitet (0-10)" }),
245
- /* @__PURE__ */ jsx("div", { className: "bmap__scale", role: "group", "aria-label": "Intensitet", children: Array.from({ length: 11 }, (_, n) => /* @__PURE__ */ jsx(
474
+ /* @__PURE__ */ jsx("p", { className: "bmap__lbl", id: "bmap-intensity-lbl", children: "Intensitet (0-10)" }),
475
+ /* @__PURE__ */ jsx("div", { className: "bmap__scale", role: "group", "aria-labelledby": "bmap-intensity-lbl", children: Array.from({ length: 11 }, (_, n) => /* @__PURE__ */ jsx(
246
476
  "button",
247
477
  {
248
478
  type: "button",
249
479
  className: "bmap__i" + (current?.intensity === n ? " bmap__i--on" : ""),
250
480
  "data-testid": `bodymap-intensity-${n}`,
481
+ "aria-label": `Intensitet ${n}`,
251
482
  "aria-pressed": current?.intensity === n,
252
483
  onClick: () => setPain(region.key, n, current?.type),
253
484
  children: n
254
485
  },
255
486
  n
256
487
  )) }),
257
- /* @__PURE__ */ jsx("p", { className: "bmap__lbl", children: "Kvalitet" }),
258
- /* @__PURE__ */ jsx("div", { className: "bmap__chips", role: "group", "aria-label": "Kvalitet", children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsx(
488
+ /* @__PURE__ */ jsx("p", { className: "bmap__lbl", id: "bmap-quality-lbl", children: "Kvalitet" }),
489
+ /* @__PURE__ */ jsx("div", { className: "bmap__chips", role: "group", "aria-labelledby": "bmap-quality-lbl", children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsx(
259
490
  "button",
260
491
  {
261
492
  type: "button",
@@ -268,7 +499,7 @@ function BodyMap({ value, defaultValue, onChange, config = {}, className }) {
268
499
  t
269
500
  )) }),
270
501
  current && /* @__PURE__ */ jsx("button", { type: "button", className: "bmap__rm", "data-testid": "bodymap-remove", onClick: () => removePain(region.key), children: "Fjern punkt" })
271
- ] }) : /* @__PURE__ */ jsx("div", { className: "bmap__panel bmap__panel--empty", "data-testid": "bodymap-panel", children: "Klik en kropsdel for at markere smerte." })
502
+ ] }) : /* @__PURE__ */ jsx("div", { className: "bmap__panel bmap__panel--empty", "data-testid": "bodymap-panel", children: "V\xE6lg en kropsdel for at markere smerte." })
272
503
  ] });
273
504
  }
274
505