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