diff --git a/apps/docs/src/components/try-map/demo.ts b/apps/docs/src/components/try-map/demo.ts index 177bca69..a5c8591f 100644 --- a/apps/docs/src/components/try-map/demo.ts +++ b/apps/docs/src/components/try-map/demo.ts @@ -29,7 +29,6 @@ import { type ShapeInput, STAIR_MODELS, searchShapes, - shapeOnAdd, suggestShapes, } from '@kept/shared'; import type { @@ -63,8 +62,9 @@ import { roomOn, seated, takeOff, + thingItem, } from '../../../../web/src/map/layout'; -import { matches, whereOf } from '../../../../web/src/map/list'; +import { addOn, matches, offMapFor, whereOf } from '../../../../web/src/map/list'; // Inline, not imported as stylesheets: Astro would link those from every page of the site. import mapCss from '../../../../web/src/map/map.css?inline'; // D249: the shape picker's pictures, the app's own sprite (fetched when the picker first opens). @@ -594,35 +594,14 @@ export function mount(root: HTMLElement, { lang, installHref }: MountOptions): ( /** Shapes picked in this session, kept if the thing goes off the map and back on (D249). */ const chosen = new Map(); + const chosenShape = (id: string) => chosen.get(id) ?? null; + function addThing(roomId: string, id: string) { const x = view.things.find((t) => t.id === id); if (!x) return; mutate((p) => { const room = p.rooms.find((r) => r.id === roomId); - if (!room) return; - const { model, from } = shapeOnAdd( - { name: x.name, typeKey: x.typeKey, typeName: x.typeName, isContainer: x.isContainer }, - chosen.get(x.id) ?? null, - ); - const it: Item = { - id: x.id, - subject: 'thing', - roomId, - name: x.name, - shortCode: x.shortCode, - model, - x: room.w / 2 - 0.3, - z: room.d / 2 - 0.3, - rot: 0, - on: null, - parentId: x.parentId, - isContainer: x.isContainer, - quantity: x.quantity, - auto: false, - ...(from === 'chosen' ? {} : { shapeFrom: from }), - }; - clampIn(it, room); - p.items.push(it); + if (room) p.items.push(thingItem(x, room, chosenShape(x.id))); }); engine?.select({ type: 'item', id: x.id }); } @@ -745,6 +724,8 @@ export function mount(root: HTMLElement, { lang, installHref }: MountOptions): ( if (!host) return; const room = plan.rooms.find((r) => r.id === host.roomId)?.name ?? ''; const things = addableOn(plan.items, host); + // #152: the room's things not on the map yet, with the shape each would get. + const offMap = offMapFor(plan, view, host, chosenShape); const max = capacityOf(host.model); const limited = Number.isFinite(max); const left = roomOn(plan.items, host); @@ -757,10 +738,14 @@ export function mount(root: HTMLElement, { lang, installHref }: MountOptions): ( : ''; const sub = (c: Item) => c.shortCode ? `${names[c.model]} · ${printedCode(c.shortCode)}` : names[c.model]; + const group = (label: string, list: Item[], heading: boolean) => + list.length + ? `
${heading ? `` : ''}${list.map((c) => putRow(c, sub(c))).join('')}
` + : ''; const body = full ? '' - : things.length - ? `
${things.map((c) => putRow(c, sub(c))).join('')}
+ : things.length || offMap.length + ? `
${group(w.put.onMap, things, offMap.length > 0)}${group(w.put.notOnMap, offMap, true)}
${btn('putcancel', w.cancel)}
` : `

${esc(carryOf(host.model) === 'arm' ? w.put.noMonitor(room) : w.put.allOn(room))}

`; const dlg = sheet( @@ -798,14 +783,7 @@ export function mount(root: HTMLElement, { lang, installHref }: MountOptions): ( add?.addEventListener('click', () => { const ids = [...picked]; dlg.close(); - mutate((p) => { - const h = p.items.find((x) => x.id === id); - if (!h) return; - for (const cid of ids) { - const t = p.items.find((x) => x.id === cid); - if (t) putOn(p.items, t, h); - } - }); + mutate((p) => addOn(p, view, id, ids, chosenShape)); engine?.select({ type: 'item', id }); }); dlg.querySelector('[role="option"][tabindex="0"]')?.focus(); diff --git a/apps/docs/src/components/try-map/strings.ts b/apps/docs/src/components/try-map/strings.ts index c5bbc570..1a74bc21 100644 --- a/apps/docs/src/components/try-map/strings.ts +++ b/apps/docs/src/components/try-map/strings.ts @@ -223,6 +223,9 @@ type Words = { left: (n: number, f: string) => string; noMonitor: (room: string) => string; allOn: (room: string) => string; + /** #152: Add on it's two lists. */ + onMap: string; + notOnMap: string; putN: (n: number, f: string) => string; pick: string; onItNow: (names: string) => string; @@ -365,8 +368,10 @@ export const WORDS: Record = { toAdd: 'Things to add on it', full: (n, f) => (n === 1 ? 'Full: it holds one monitor.' : `Full: it holds ${f} monitors.`), left: (n, f) => (n === 1 ? 'Room for one more.' : `Room for ${f} more.`), - noMonitor: (room) => `No monitor in ${room} is free. Put one on the map first.`, - allOn: (room) => `Everything small in ${room} is on something, or not on the map yet.`, + noMonitor: (room) => `No monitor in ${room} is free.`, + allOn: (room) => `Everything small in ${room} is on something already.`, + onMap: 'On the map', + notOnMap: 'Not on the map yet', putN: (_n, f) => `Put ${f} on it`, pick: 'Pick what goes on it', onItNow: (names) => `On it now: ${names}`, @@ -524,8 +529,10 @@ export const WORDS: Record = { 'مكان لـ# أخرى.', 'مكان لـ# أخرى.', ]), - noMonitor: (room) => `لا توجد شاشة متاحة في ${room}. ضع واحدة على الخريطة أولًا.`, - allOn: (room) => `كل شيء صغير في ${room} موضوع على شيء، أو ليس على الخريطة بعد.`, + noMonitor: (room) => `لا توجد شاشة متاحة في ${room}.`, + allOn: (room) => `كل شيء صغير في ${room} موضوع على شيء بالفعل.`, + onMap: 'على الخريطة', + notOnMap: 'ليست على الخريطة بعد', putN: (n, f) => arPlural(n, f, [ 'ضع # عليه', diff --git a/apps/docs/src/content/docs/users/location-map.mdx b/apps/docs/src/content/docs/users/location-map.mdx index 3b5cb9a1..e1be22a3 100644 --- a/apps/docs/src/content/docs/users/location-map.mdx +++ b/apps/docs/src/content/docs/users/location-map.mdx @@ -60,6 +60,9 @@ The floor buttons work like a lift: **R**, **1**, **G** and so on, each named af buttons, and step aside while a card is open. **Plan** looks straight down, with names and what's in each shelf. Stairs on the map take you to the floor above or below. +A room opens facing the front of its screens (a monitor, a TV, a laptop), so you see them, not +their backs. Turn it another way and it opens that way next time on this device. + ![Plan: the same home seen from above, each room named](../../../assets/screens/map-plan-light.webp) ![The map on a phone: the List · Grid · Map switch, the 3D and Plan buttons, and a selected thing's card at the bottom](../../../assets/screens/map-phone-light.webp) @@ -159,6 +162,8 @@ A webcam is a few pixels wide at room zoom, so you don't have to drag it. In **A 3. To go the other way, select the desk or the monitor and tap **Add on it**. Pick one or several of the room's small things that aren't on anything yet, then **Put 3 on it**. They go side by side, not on top of each other. + The room's small things that aren't on the map yet are there too, under **Not on the map yet**, + with the shape each would get: picking one puts it on the map and on the desk in one go. 4. **Take off**, in **Put on…**, puts the thing back on the room's floor next to its holder. ![Put on… for a webcam: the room's desk and monitor, each with its picture](../../../assets/screens/map-put-on-phone-light.webp) @@ -170,7 +175,10 @@ What can carry what: light: small things up to 12 cm deep and 20 cm high, no wider than the screen. - **On an arm**: a **monitor arm** stands on a desk and holds one monitor, a **dual monitor arm** two, side by side and raised off the desk. **Add on it** on an arm lists the room's monitors - and says when the arm is full. A webcam on a monitor on an arm still works. + and says when the arm is full. A webcam on a monitor on an arm still works. An arm clamps at + the desk's back edge, the side away from the chair: in the middle, or at a back corner when the + middle is taken. Its monitors hang over the desk in front of it, and dragging it slides it along + the back. Moving a holder moves what is on it: move the desk and its arm, the monitors and their webcam go with it. Turn the desk and they turn with it. This is the map only: putting a webcam on a monitor diff --git a/apps/web/src/locales/ar/messages.po b/apps/web/src/locales/ar/messages.po index 10e89a1e..f832e33f 100644 --- a/apps/web/src/locales/ar/messages.po +++ b/apps/web/src/locales/ar/messages.po @@ -25137,3 +25137,19 @@ msgstr "اطلب من المالك إعداد البريد الإلكتروني. #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "أضف عنوان بريد إلكتروني إلى حسابك." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "على الخريطة" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "ليست على الخريطة بعد" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "لا توجد شاشة متاحة في {roomName}." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "كل شيء صغير في {roomName} موضوع على شيء بالفعل." diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index cd01e469..fcb8a0d7 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -25137,3 +25137,19 @@ msgstr "Bitte den Eigentümer, E-Mail einzurichten." #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "Füge deinem Konto eine E-Mail-Adresse hinzu." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "Auf der Karte" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "Noch nicht auf der Karte" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "Kein freier Monitor in {roomName}." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "Alles Kleine in {roomName} steht schon auf etwas." diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index 6a6e1060..42e915dd 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -25137,3 +25137,19 @@ msgstr "Ask the owner to set up email." #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "Add an email address to your account." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "On the map" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "Not on the map yet" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "No monitor in {roomName} is free." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "Everything small in {roomName} is on something already." diff --git a/apps/web/src/locales/fr/messages.po b/apps/web/src/locales/fr/messages.po index 1f4a3d05..e5e08ac2 100644 --- a/apps/web/src/locales/fr/messages.po +++ b/apps/web/src/locales/fr/messages.po @@ -25137,3 +25137,19 @@ msgstr "Demandez au propriétaire de configurer l’e-mail." #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "Ajoutez une adresse e-mail à votre compte." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "Sur la carte" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "Pas encore sur la carte" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "Aucun écran libre dans {roomName}." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "Tout ce qui est petit dans {roomName} est déjà posé sur quelque chose." diff --git a/apps/web/src/locales/it/messages.po b/apps/web/src/locales/it/messages.po index c6b350b2..d773b32e 100644 --- a/apps/web/src/locales/it/messages.po +++ b/apps/web/src/locales/it/messages.po @@ -25137,3 +25137,19 @@ msgstr "Chiedi al proprietario di configurare l'email." #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "Aggiungi un indirizzo email al tuo account." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "Sulla mappa" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "Non ancora sulla mappa" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "Nessun monitor libero in {roomName}." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "Tutto ciò che è piccolo in {roomName} è già sopra qualcosa." diff --git a/apps/web/src/map/card.tsx b/apps/web/src/map/card.tsx index 59fb4465..0b3ed73b 100644 --- a/apps/web/src/map/card.tsx +++ b/apps/web/src/map/card.tsx @@ -41,7 +41,7 @@ import { type Room, seated, } from './layout'; -import { matches, whereOf } from './list'; +import { type ChosenShape, matches, whereOf } from './list'; import { AddOnSheet, PutOnSheet } from './put-on'; import { ShapeField, shapeInputOf } from './shapes'; @@ -74,6 +74,8 @@ export type CardProps = { onPutOn: (id: string, hostId: string) => void; onTakeOff: (id: string) => void; onAddOn: (hostId: string, ids: string[]) => void; + /** #152: the shape a thing not on the map would get back, for Add on it. */ + chosenShape?: ChosenShape; }; function Dots({ flags }: { flags: MapAttentionKind[] }) { @@ -598,6 +600,8 @@ export function MapCard(p: CardProps) { setSheet(open ? 'add' : null)} onAdd={(ids) => { diff --git a/apps/web/src/map/engine/engine.ts b/apps/web/src/map/engine/engine.ts index 3caf6e21..10cd9a22 100644 --- a/apps/web/src/map/engine/engine.ts +++ b/apps/web/src/map/engine/engine.ts @@ -24,6 +24,7 @@ import { childrenOn, clampIn, dropOn, + facingAz, fp, hostAt, type Item, @@ -33,10 +34,12 @@ import { type Room, rehome, roomAt, + screensFacing, snap, standingOn, turn, } from '../layout'; +import { nearestTurn, readTurn, writeTurn } from '../turns'; import { block, cy, @@ -209,6 +212,8 @@ export class MapEngine { prev: ReturnType; } | null = null; private lastTap = { t: 0, key: '' }; + /** Set when two fingers turned the view; the room it was turned in remembers it (#150). */ + private twisted = false; private drag: | { type: 'item'; id: string; off: THREE.Vector3 } | { type: 'resize'; id: string } @@ -878,7 +883,7 @@ export class MapEngine { return b; } - private fitTo(b: THREE.Box3, animate = true): void { + private fitTo(b: THREE.Box3, animate = true, az?: number): void { const c = b.getCenter(new THREE.Vector3()); const s = b.getSize(new THREE.Vector3()); const w = this.stage.clientWidth || 1; @@ -904,11 +909,13 @@ export class MapEngine { const units = ((this.view.span / zoom) * ((top - bottom) / 2)) / full; const away = this.s.planView ? new THREE.Vector3(0, 0, -units) - : new THREE.Vector3(-Math.sin(this.view.az), 0, -Math.cos(this.view.az)).multiplyScalar( - units / Math.sin(ELEV), - ); + : new THREE.Vector3( + -Math.sin(az ?? this.view.az), + 0, + -Math.cos(az ?? this.view.az), + ).multiplyScalar(units / Math.sin(ELEV)); const target = new THREE.Vector3(c.x, this.s.allFloors ? c.y - 1 : 0, c.z).add(away); - this.tween({ target, zoom }, animate); + this.tween({ target, zoom, ...(az === undefined ? {} : { az }) }, animate); } fitLevel = (animate = true): void => @@ -927,7 +934,19 @@ export class MapEngine { } turnBy(quarters: number): void { - this.tween({ az: this.view.az + (quarters * Math.PI) / 2 }); + const az = this.view.az + (quarters * Math.PI) / 2; + this.tween({ az }); + if (this.s.focused) writeTurn(this.s.focused, az); + } + + /** + * The turn a room opens at (#150): the one it was last left at on this device, else facing the + * front of its screens, else none (the view keeps its turn). The short way round. + */ + private roomTurn(roomId: string): number | undefined { + const rot = screensFacing(this.plan.items, roomId); + const want = readTurn(roomId) ?? (rot === null ? null : facingAz(rot)); + return want === null ? undefined : nearestTurn(this.view.az, want); } /** Home: out of a room, else the whole floor. */ @@ -1234,7 +1253,7 @@ export class MapEngine { if (this.s.allFloors || r.levelKey !== this.s.levelKey) this.setLevel(r.levelKey, false); this.s.focused = roomId; this.applyFocus(); - this.fitTo(this.boundsOfRooms([r])); + this.fitTo(this.boundsOfRooms([r]), true, this.roomTurn(roomId)); this.emit(); } @@ -1511,7 +1530,10 @@ export class MapEngine { if (a && b) this.view.target.add(a.sub(b)); if (now.n === 2 && prev.d > 0) { this.view.zoom = Math.max(0.2, Math.min(10, (this.view.zoom * now.d) / prev.d)); - if (!this.s.planView) this.view.az -= now.ang - prev.ang; + if (!this.s.planView && now.ang !== prev.ang) { + this.view.az -= now.ang - prev.ang; + this.twisted = true; + } } } this.gesture.prev = now; @@ -1660,6 +1682,10 @@ export class MapEngine { gesture.prev = this.snapshot(); return; } + if (this.twisted) { + this.twisted = false; + if (this.s.focused) writeTurn(this.s.focused, this.view.az); + } if (performance.now() - gesture.start < 350 && gesture.moved < 8) { const p = this.pick(e.clientX, e.clientY); if (this.s.allFloors) { diff --git a/apps/web/src/map/facing.test.ts b/apps/web/src/map/facing.test.ts new file mode 100644 index 00000000..c67f9adf --- /dev/null +++ b/apps/web/src/map/facing.test.ts @@ -0,0 +1,109 @@ +import { turnOffset } from '@kept/shared'; +import { afterEach, describe, expect, it } from 'vitest'; +import { facingAz, type Item, screensFacing } from './layout'; +import { nearestTurn, readTurn, writeTurn } from './turns'; + +// #150: a room opens facing the front of its screens, or as it was last turned on this device. + +const ROOM = 'room-study'; +let n = 0; +const item = (over: Partial): Item => ({ + id: `i${n++}`, + subject: 'thing', + roomId: ROOM, + name: 'x', + shortCode: null, + model: 'item', + x: 1, + z: 1, + rot: 0, + on: null, + parentId: ROOM, + isContainer: false, + quantity: 1, + auto: false, + ...over, +}); + +describe('screensFacing', () => { + it('is null in a room without a screen', () => { + expect(screensFacing([item({ model: 'desk' }), item({ model: 'webcam' })], ROOM)).toBeNull(); + }); + + it('takes the turn of the room’s screens, a TV counting more than a laptop', () => { + const items = [ + item({ model: 'desk', rot: 90 }), + item({ model: 'monitor', rot: 180 }), + item({ model: 'laptop', rot: 90 }), + item({ model: 'tv', rot: 270, roomId: 'elsewhere' }), + ]; + expect(screensFacing(items, ROOM)).toBe(180); + expect(screensFacing([...items, item({ model: 'tv', rot: 90 })], ROOM)).toBe(90); + }); + + it('keeps the first turn met on a tie', () => { + const items = [item({ model: 'monitor', rot: 270 }), item({ model: 'monitor', rot: 0 })]; + expect(screensFacing(items, ROOM)).toBe(270); + }); +}); + +describe('facingAz', () => { + it('is the map’s usual view for an unturned screen', () => { + expect(facingAz(0)).toBeCloseTo(Math.PI / 4); + // A screen facing +x is seen from the same corner, a whole turn away or not. + expect(Math.cos(facingAz(270) - Math.PI / 4)).toBeCloseTo(1); + }); + + it('looks from the side with fewer walls in the way', () => { + // A screen turned half round faces −z: from +x, so only the −z wall is between. + expect(Math.sin(facingAz(180))).toBeGreaterThan(0); + expect(Math.cos(facingAz(90))).toBeGreaterThan(0); + }); + + it('puts the camera on the screen’s front side for every turn', () => { + for (const rot of [0, 90, 180, 270]) { + const az = facingAz(rot); + const front = turnOffset(0, 1, rot); + // The camera sits towards (sin az, cos az): in front of the screen, 45° to its side. + const dot = Math.sin(az) * front.x + Math.cos(az) * front.z; + expect(dot).toBeCloseTo(Math.SQRT1_2); + } + }); +}); + +describe('remembered turns', () => { + afterEach(() => localStorage.clear()); + + it('remembers a room’s last turn on this device', () => { + expect(readTurn(ROOM)).toBeNull(); + writeTurn(ROOM, Math.PI / 4 + Math.PI * 2); + expect(readTurn(ROOM)).toBeCloseTo(Math.PI / 4, 3); + writeTurn(ROOM, -Math.PI / 4); + expect(readTurn(ROOM)).toBeCloseTo((7 * Math.PI) / 4, 3); + }); + + it('survives storage that is broken or throws', () => { + localStorage.setItem('kept.map.turns', '{not json'); + expect(readTurn(ROOM)).toBeNull(); + const set = Storage.prototype.setItem; + Storage.prototype.setItem = () => { + throw new Error('quota'); + }; + try { + expect(() => writeTurn(ROOM, 1)).not.toThrow(); + } finally { + Storage.prototype.setItem = set; + } + }); + + it('keeps the newest rooms when there are many', () => { + for (let i = 0; i < 205; i++) writeTurn(`r${i}`, 1); + expect(readTurn('r0')).toBeNull(); + expect(readTurn('r204')).toBe(1); + }); + + it('turns the short way round', () => { + expect(nearestTurn(Math.PI * 4 + 0.1, 0.2)).toBeCloseTo(Math.PI * 4 + 0.2); + expect(nearestTurn(-0.1, Math.PI * 1.9)).toBeCloseTo(-Math.PI * 0.1); + }); +}); diff --git a/apps/web/src/map/holders.test.ts b/apps/web/src/map/holders.test.ts index a103b457..ff00ef20 100644 --- a/apps/web/src/map/holders.test.ts +++ b/apps/web/src/map/holders.test.ts @@ -1,10 +1,11 @@ -import { mToCm, rectsOverlap } from '@kept/shared'; +import { mToCm, rectsOverlap, turnOffset, unturnOffset } from '@kept/shared'; import { describe, expect, it } from 'vitest'; import type { MapView } from '@/api/map/types'; import { addableOn, bodyOf, centreOf, + clampsAtBack, dropOn, fp, freeAlong, @@ -356,3 +357,85 @@ describe('old saves (#137)', () => { expect(on(id(5))).toBeNull(); }); }); + +describe('arms clamp at the back edge (#151)', () => { + // A desk's front is +z in its own frame: the back edge is the side away from the chair. + const BACK = -(fp({ model: 'desk', rot: 0 }).d / 2 - 0.05); + const local = (desk: Item, t: Item) => { + const dc = centreOf(desk); + const c = centreOf(t); + return unturnOffset(c.x - dc.x, c.z - dc.z, desk.rot); + }; + + it('stands a dual arm at the middle of a Corner desk’s back edge, monitors over the desk', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1 }); + const arm = item({ model: 'dual_monitor_arm', name: 'Dual arm', x: 3, z: 3 }); + const left = item({ model: 'monitor', name: 'Left' }); + const right = item({ model: 'monitor', name: 'Right' }); + const items = [desk, arm, left, right]; + expect(clampsAtBack(arm.model)).toBe(true); + expect(clampsAtBack('webcam')).toBe(false); + expect(putOn(items, arm, desk)).toBe(true); + near(local(desk, arm), { x: 0, z: BACK }); + putOn(items, left, arm); + putOn(items, right, arm); + // In front of the pole, and over the desk. + for (const m of [left, right]) { + expect(local(desk, m).z).toBeGreaterThan(local(desk, arm).z); + expect(Math.abs(local(desk, m).z)).toBeLessThan(fp(desk).d / 2); + } + }); + + it('takes a back corner when the middle of the back is taken', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1 }); + const first = item({ model: 'monitor_arm', name: 'Arm' }); + const second = item({ model: 'monitor_arm', name: 'Arm 2' }); + const items = [desk, first, second]; + putOn(items, first, desk); + putOn(items, second, desk); + near(local(desk, first), { x: 0, z: BACK }); + near(local(desk, second), { x: -(fp(desk).w / 2 - 0.05), z: BACK }); + }); + + it('puts an arm on a turned desk at that desk’s back, facing its way', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1, rot: 180 }); + const arm = item({ model: 'monitor_arm', name: 'Arm' }); + const items = [desk, arm]; + putOn(items, arm, desk); + expect(arm.rot).toBe(180); + near(local(desk, arm), { x: 0, z: BACK }); + // In the room, the back of a desk turned half round is towards +z. + expect(centreOf(arm).z).toBeGreaterThan(centreOf(desk).z); + }); + + it('keeps the arm at the back when the desk turns', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1 }); + const arm = item({ model: 'dual_monitor_arm', name: 'Dual arm' }); + const left = item({ model: 'monitor', name: 'Left' }); + const items = [desk, arm, left]; + putOn(items, arm, desk); + putOn(items, left, arm); + for (let i = 0; i < 4; i++) { + turn(items, rooms, desk); + near(local(desk, arm), { x: 0, z: BACK }); + expect(local(desk, left).z).toBeGreaterThan(local(desk, arm).z); + } + }); + + it('snaps a dragged arm onto the back edge, sliding along it', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1, rot: 90 }); + const arm = item({ model: 'monitor_arm', name: 'Arm' }); + const items = [desk, arm]; + const dc = centreOf(desk); + // Dropped near the desk's front, a little to one side. + const p = turnOffset(0.3, 0.2, desk.rot); + dropOn(items, arm, desk, { x: dc.x + p.x, z: dc.z + p.z }); + expect(arm.on).toBe(desk.id); + expect(arm.rot).toBe(90); + near(local(desk, arm), { x: 0.3, z: BACK }); + // Past the end, it stops at the corner. + const far = turnOffset(2, 0, desk.rot); + dropOn(items, arm, desk, { x: dc.x + far.x, z: dc.z + far.z }); + near(local(desk, arm), { x: fp({ model: 'desk', rot: 0 }).w / 2 - 0.05, z: BACK }); + }); +}); diff --git a/apps/web/src/map/layout.ts b/apps/web/src/map/layout.ts index eac22662..bea4db0d 100644 --- a/apps/web/src/map/layout.ts +++ b/apps/web/src/map/layout.ts @@ -27,6 +27,7 @@ import { ROOM_KIND_SURFACE, rectsOverlap, type ShapeFrom, + shapeOnAdd, snapStep, turnOffset, unturnOffset, @@ -401,6 +402,46 @@ export function turn(items: readonly Item[], rooms: readonly Room[], it: Item): } } +// ---- Facing the screens (#150) ---------------------------------------------------------------- + +/** + * The turn most of a room's screens have (a monitor, a TV, a laptop: the models with a screen + * edge), each counted by its width so a TV outweighs a laptop; null when the room has none. Ties + * go to the first turn met. + */ +export function screensFacing(items: readonly Item[], roomId: string): number | null { + const weight = new Map(); + for (const it of items) { + if (it.roomId !== roomId || carryOf(it.model) !== 'edge') continue; + const r = ((it.rot % 360) + 360) % 360; + weight.set(r, (weight.get(r) ?? 0) + mapModel(it.model).w); + } + let best: number | null = null; + let most = 0; + for (const [r, w] of weight) { + if (w > most + 1e-9) { + best = r; + most = w; + } + } + return best; +} + +/** + * The 3D view's turn that looks at a model of this turn from its front, half a quarter to one side + * as the map always looks (the camera sits towards (sin az, cos az) from what it looks at, and a + * model's front points at turnOffset(0, 1, rot)). Of the two sides, the one with fewer of a + * room's walls between the camera and the room (they stand on its −x and −z sides): π/4, the + * map's usual view, for an unturned screen. + */ +export function facingAz(rot: number): number { + const front = -(rot * Math.PI) / 180; + const hidden = (az: number) => Number(Math.sin(az) < -1e-9) + Number(Math.cos(az) < -1e-9); + const a = front + Math.PI / 4; + const b = front - Math.PI / 4; + return hidden(b) < hidden(a) ? b : a; +} + // ---- Holders (#137): putting a thing on a desk, a screen's edge or an arm, and off again ------ /** Metres between things put side by side on a holder. */ @@ -481,12 +522,23 @@ export function freeSpot( return out; }; const xs = steps(xr, x0); + const r2 = (v: number) => Math.round(v * 100) / 100; + // #151: an arm clamps at the back edge (away from the chair, the top's front being +z): in the + // middle, else a back corner, else anywhere along the back; only then where anything else goes. + if (clampsAtBack(it.model) && m.top !== undefined) { + const corners = [-xr, xr].sort((a, b) => Math.abs(a - near) - Math.abs(b - near)); + for (const x of [0, ...corners, ...xs]) + if (fits(x, -zr)) return { along: r2(x), depth: r2(-zr) }; + } for (const z of steps(zr, 0)) for (const x of xs) if (fits(x, z)) return { along: Math.round(x * 100) / 100, depth: Math.round(z * 100) / 100 }; return { along: x0, depth: 0 }; } +/** A monitor arm (#151): it clamps at the back edge of a top, facing the top's way. */ +export const clampsAtBack = (model: MapModelKey): boolean => carryOf(model) === 'arm'; + /** Across a holder only: an arm's free place or an edge's free spot (null when an arm is full). */ export function freeAlong( items: readonly Item[], @@ -624,11 +676,53 @@ export function dropOn( return; } const step = free ? 0.01 : snapStep(it.model); - const p = turnOffset(snap(local.x, step), snap(local.z, step), host.rot); + let lx = snap(local.x, step); + let lz = snap(local.z, step); + if (clampsAtBack(it.model)) { + // #151: an arm slides along the back edge only, facing the top's way. + const hm = mapModel(host.model); + const im = mapModel(it.model); + const xr = Math.max(0, hm.w / 2 - im.w / 2); + lx = Math.min(xr, Math.max(-xr, lx)); + lz = -Math.max(0, hm.d / 2 - im.d / 2); + turnStack(items, it, (host.rot - it.rot) / 90); + } + const p = turnOffset(lx, lz, host.rot); it.on = host.id; centreAt(items, it, { x: hc.x + p.x, z: hc.z + p.z }); } +/** + * A thing of a room as it goes on the map (D249): the shape someone already chose for it, else + * the first suggestion from its type and name (`shapeFrom` says which, or that it is a guess), + * in the middle of the room, unturned, on nothing. + */ +export function thingItem(x: MapThingView, room: Room, chosen: MapModelKey | null = null): Item { + const { model, from } = shapeOnAdd( + { name: x.name, typeKey: x.typeKey, typeName: x.typeName, isContainer: x.isContainer }, + chosen, + ); + const it: Item = { + id: x.id, + subject: 'thing', + roomId: room.id, + name: x.name, + shortCode: x.shortCode, + model, + x: room.w / 2 - 0.3, + z: room.d / 2 - 0.3, + rot: 0, + on: null, + parentId: x.parentId, + isContainer: x.isContainer, + quantity: x.quantity, + auto: false, + ...(from === 'chosen' ? {} : { shapeFrom: from }), + }; + clampIn(it, room); + return it; +} + /** Whether a rectangle overlaps another room on the level. */ export function overlapsRoom( rooms: readonly Room[], diff --git a/apps/web/src/map/list.ts b/apps/web/src/map/list.ts index 073b05d1..4532d3b9 100644 --- a/apps/web/src/map/list.ts +++ b/apps/web/src/map/list.ts @@ -3,15 +3,19 @@ * in them, or what needs attention (low stock, lent out, due soon), with a search, a floor * filter and Show more. Pure, so the filters are tested without the map. */ +import { canBeCarried, fitsOn, type MapModelKey } from '@kept/shared'; import type { MapAttention, MapAttentionKind, MapView } from '@/api/map/types'; import { allUnder, type Contents, + canPutOn, flagsOf, type Item, type Level, type Plan, + putOn, type Room, + thingItem, } from './layout'; export type ListShow = 'room' | 'attention'; @@ -173,6 +177,63 @@ export function whereOf(plan: Plan, view: Pick, id: string) { return { path, roomId }; } +/** A shape someone chose for a thing in this session, or that it has on the saved map. */ +export type ChosenShape = (id: string) => MapModelKey | null; + +/** + * Add on it's second list (#152): the holder's room's things that aren't on the map yet and, with + * the shape they'd get (thingItem), are small enough to go on something (they'd get Put on…) and + * fit on this holder; each as the item it would become, by name. + */ +export function offMapFor( + plan: Plan, + view: Pick, + host: Item, + chosen: ChosenShape = () => null, +): Item[] { + const room = plan.rooms.find((r) => r.id === host.roomId); + if (!room) return []; + const onMap = new Set(plan.items.map((i) => i.id)); + return view.things + .filter((x) => !onMap.has(x.id) && whereOf(plan, view, x.id).roomId === room.id) + .map((x) => thingItem(x, room, chosen(x.id))) + .filter( + (it) => + canBeCarried(it.model) && fitsOn(host.model, it.model) && canPutOn(plan.items, host, it), + ) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Add on it, in one step (#152): puts the picked things on a holder side by side; one not on the + * map yet goes on it first, as offMapFor showed it. One change to the plan, so one Done saves it + * and one Undo takes it back. + */ +export function addOn( + plan: Plan, + view: Pick, + hostId: string, + ids: readonly string[], + chosen: ChosenShape = () => null, +): void { + const host = plan.items.find((i) => i.id === hostId); + const room = plan.rooms.find((r) => r.id === host?.roomId); + if (!host || !room) return; + for (const id of ids) { + const was = plan.items.find((i) => i.id === id); + if (was) { + putOn(plan.items, was, host); + continue; + } + const x = view.things.find((t) => t.id === id); + if (!x || whereOf(plan, view, id).roomId !== room.id) continue; + const it = thingItem(x, room, chosen(id)); + plan.items.push(it); + // It goes on the map only to go on the holder: if it can't (the holder filled up), it doesn't. + if (!putOn(plan.items, it, host)) plan.items.pop(); + } +} + export function attentionGroups( plan: Plan, view: Pick, diff --git a/apps/web/src/map/map-view.tsx b/apps/web/src/map/map-view.tsx index d74d9c57..094f39c2 100644 --- a/apps/web/src/map/map-view.tsx +++ b/apps/web/src/map/map-view.tsx @@ -17,7 +17,6 @@ import { type MapRoomKind, type MapSurface, ROOM_KIND_SURFACE, - shapeOnAdd, } from '@kept/shared'; import { Trans, useLingui } from '@lingui/react/macro'; import { useQueryClient } from '@tanstack/react-query'; @@ -48,14 +47,14 @@ import { contentsOf, countIn, flagsOf, - type Item, type Plan, planOf, putOn, type Room, takeOff, + thingItem, } from './layout'; -import { type ListQuery, type ListShow, whereOf } from './list'; +import { addOn, type ListQuery, type ListShow, whereOf } from './list'; import { WhatsIn } from './whats-in'; import './map.css'; @@ -332,36 +331,18 @@ function MapBody({ data, locationId, name }: { data: MapData; locationId: string /** Shapes someone picked in this session, kept if the thing goes off the map and back on. */ const chosen = useRef(new Map()); + /** D249: a shape someone chose stays (in this session, or on the saved map); otherwise the + * first suggestion from its type and name, said on the card ("from its type") when Kept is + * sure, a guess to check when not. */ + const chosenShape = (id: string): MapModelKey | null => { + const stored = data.nodes.find((n) => n.id === id)?.layout?.model; + return chosen.current.get(id) ?? (isMapModel(stored) ? stored : null); + }; + function addThing(roomId: string, x: MapThingView) { mutate((p) => { const room = p.rooms.find((r) => r.id === roomId); - if (!room) return; - // D249: a shape someone chose stays; otherwise the first suggestion from its type and - // name, said on the card ("from its type") when Kept is sure, a guess to check when not. - const stored = data.nodes.find((n) => n.id === x.id)?.layout?.model; - const { model, from } = shapeOnAdd( - { name: x.name, typeKey: x.typeKey, typeName: x.typeName, isContainer: x.isContainer }, - chosen.current.get(x.id) ?? (isMapModel(stored) ? stored : null), - ); - const it: Item = { - id: x.id, - subject: 'thing', - roomId, - name: x.name, - shortCode: x.shortCode, - model, - x: room.w / 2 - 0.3, - z: room.d / 2 - 0.3, - rot: 0, - on: null, - parentId: x.parentId, - isContainer: x.isContainer, - quantity: x.quantity, - auto: false, - ...(from === 'chosen' ? {} : { shapeFrom: from }), - }; - clampIn(it, room); - p.items.push(it); + if (room) p.items.push(thingItem(x, room, chosenShape(x.id))); }); engineRef.current?.select({ type: 'item', id: x.id }); } @@ -403,15 +384,9 @@ function MapBody({ data, locationId, name }: { data: MapData; locationId: string engineRef.current?.select({ type: 'item', id }); } + /** #152: things not on the map yet go on it and on the holder in the same change. */ function addOnIt(hostId: string, ids: string[]) { - mutate((p) => { - const host = p.items.find((i) => i.id === hostId); - if (!host) return; - for (const id of ids) { - const it = p.items.find((i) => i.id === id); - if (it) putOn(p.items, it, host); - } - }); + mutate((p) => addOn(p, data, hostId, ids, chosenShape)); engineRef.current?.select({ type: 'item', id: hostId }); } @@ -876,6 +851,7 @@ function MapBody({ data, locationId, name }: { data: MapData; locationId: string onPutOn={putThingOn} onTakeOff={takeThingOff} onAddOn={addOnIt} + chosenShape={chosenShape} /> ) : null} diff --git a/apps/web/src/map/map.css b/apps/web/src/map/map.css index 61f242e0..a7da4926 100644 --- a/apps/web/src/map/map.css +++ b/apps/web/src/map/map.css @@ -939,6 +939,17 @@ details[open] > .kmap-orow .kmap-chev { padding: 2px; outline: none; } +.kput-section { + display: grid; + gap: 6px; +} +.kput-section + .kput-section { + margin-block-start: 8px; +} +.kput-head { + color: var(--ink-2); + font: 600 12px / 1.3 var(--sans); +} .kput-option { display: flex; align-items: center; diff --git a/apps/web/src/map/put-on.test.tsx b/apps/web/src/map/put-on.test.tsx index 3b97b769..c806b5bd 100644 --- a/apps/web/src/map/put-on.test.tsx +++ b/apps/web/src/map/put-on.test.tsx @@ -1,10 +1,11 @@ import { act, screen, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { MapView } from '@/api/map/types'; +import type { MapThingView, MapView } from '@/api/map/types'; import { printedCode } from '@/components/id-chip'; import { expectLogicalOnly, renderUI } from '@/test/render'; import { MapCard } from './card'; -import { contentsOf, type Item, type Plan, putOn } from './layout'; +import { bodyOf, contentsOf, type Item, type Plan, putOn } from './layout'; +import { addOn, offMapFor } from './list'; import { AddOnSheet, PutOnSheet } from './put-on'; // #137: Put on… and Add on it, the cards that open them, and what a viewer sees. @@ -322,3 +323,127 @@ describe('the card (#137)', () => { await act(async () => {}); }); }); + +// #152: Add on it also lists the room's things that aren't on the map yet, and puts one on the map +// and on the holder in one step. +describe('Add on it with things not on the map yet (#152)', () => { + const thing = (over: Partial): MapThingView => ({ + id: id(n++), + name: 'x', + shortCode: null, + quantity: 1, + parentId: ROOM, + typeKey: null, + typeName: null, + isContainer: false, + ...over, + }); + const things = () => { + const lamp = thing({ name: 'Desk lamp', shortCode: 'LMP456' }); + const cam = thing({ name: 'Spare webcam' }); + const fridge = thing({ name: 'Fridge' }); + const away = thing({ name: 'Hall webcam', parentId: id(77) }); + return { lamp, cam, fridge, away, list: [lamp, cam, fridge, away] }; + }; + + it('lists the room’s things that would fit, with the shape each would get', () => { + const { plan, desk, monitor } = office(); + const { lamp, cam, list } = things(); + const off = offMapFor(plan, { things: list }, desk); + expect(off.map((i) => [i.name, i.model])).toEqual([ + ['Desk lamp', 'table_lamp'], + ['Spare webcam', 'webcam'], + ]); + // Nothing moved: they are only shown. + expect(plan.items.some((i) => i.id === lamp.id || i.id === cam.id)).toBe(false); + // A screen's edge takes the webcam, not the lamp. + expect(offMapFor(plan, { things: list }, monitor).map((i) => i.name)).toEqual(['Spare webcam']); + // A shape someone chose wins. + expect(offMapFor(plan, { things: list }, desk, () => 'speaker').map((i) => i.model)).toEqual([ + 'speaker', + 'speaker', + 'speaker', + ]); + }); + + it('puts one on the map and on the desk in one change, saved in one body', () => { + const { plan, desk, webcam } = office(); + const { cam, list } = things(); + const before = plan.items.length; + addOn(plan, { things: list }, desk.id, [cam.id, webcam.id]); + expect(plan.items.length).toBe(before + 1); + const added = plan.items.find((i) => i.id === cam.id); + expect(added?.on).toBe(desk.id); + expect(added?.model).toBe('webcam'); + expect(webcam.on).toBe(desk.id); + const body = bodyOf(plan, null); + expect(body.nodes.find((x) => x.id === cam.id)).toMatchObject({ + hostId: desk.id, + model: 'webcam', + }); + }); + + it('leaves off what isn’t in the room or can’t go on', () => { + const { plan, desk } = office(); + const arm = item({ model: 'monitor_arm', name: 'Arm' }); + plan.items.push(arm); + const { cam, away, list } = things(); + const before = plan.items.length; + addOn(plan, { things: list }, desk.id, [away.id]); + addOn(plan, { things: list }, arm.id, [cam.id]); + expect(plan.items.length).toBe(before); + }); + + it('shows them under their own heading, and hands both kinds back on Put', async () => { + const { plan, desk, keyboard } = office(); + const { cam, list } = things(); + const onAdd = vi.fn(); + const { user } = await renderUI( + {}} + onAdd={onAdd} + />, + ); + expect(screen.getByText('On the map')).toBeTruthy(); + const off = screen.getByRole('group', { name: 'Not on the map yet' }); + expect( + within(off) + .getAllByRole('option') + .map((o) => o.textContent), + ).toEqual([`Desk lampTable lamp · ${printedCode('LMP456')}`, 'Spare webcamWebcam']); + await user.click(within(off).getByRole('option', { name: 'Spare webcam' })); + await user.click(screen.getByRole('option', { name: 'Keyboard' })); + await user.click(screen.getByRole('button', { name: 'Put 2 on it' })); + expect(onAdd).toHaveBeenCalledWith([cam.id, keyboard.id]); + }); + + it('shows no heading when nothing is off the map', async () => { + const { plan, desk } = office(); + await renderUI( + {}} + onAdd={vi.fn()} + />, + ); + expect(screen.queryByText('On the map')).toBeNull(); + expect(screen.queryByText('Not on the map yet')).toBeNull(); + }); + + it('opens from a holder’s card with the location’s things', async () => { + const { plan, desk } = office(); + const { list } = things(); + const v = { ...view(plan), things: list }; + const { user, props } = await card(plan, desk, true, { view: v, contents: contentsOf(v) }); + await user.click(screen.getByRole('button', { name: 'Add on it' })); + await user.click(screen.getByRole('option', { name: 'Spare webcam' })); + await user.click(screen.getByRole('button', { name: 'Put 1 on it' })); + expect(props.onAddOn).toHaveBeenCalledWith(desk.id, [list[1]?.id]); + }); +}); diff --git a/apps/web/src/map/put-on.tsx b/apps/web/src/map/put-on.tsx index 884214db..dbbe21aa 100644 --- a/apps/web/src/map/put-on.tsx +++ b/apps/web/src/map/put-on.tsx @@ -9,16 +9,26 @@ import { capacityOf, carryOf } from '@kept/shared'; import { Plural, Trans, useLingui } from '@lingui/react/macro'; import { useMemo, useState } from 'react'; -import { ListBox, ListBoxItem, type Selection, Text } from 'react-aria-components'; +import { + Header, + ListBox, + ListBoxItem, + ListBoxSection, + type Selection, + Text, +} from 'react-aria-components'; +import type { MapView } from '@/api/map/types'; import { printedCode } from '@/components/id-chip'; import { Sheet } from '@/components/things/sheet'; import { Button } from '@/components/ui/button'; import { TextField } from '@/components/ui/text-field'; import { useModelNames } from './labels'; import { addableOn, childrenOn, holdersFor, type Item, type Plan, roomOn } from './layout'; -import { matches } from './list'; +import { type ChosenShape, matches, offMapFor } from './list'; import { ShapeThumb } from './shapes'; +const NO_THINGS: Pick = { things: [] }; + /** A list longer than this gets a search. */ const SEARCH_FROM = 8; @@ -117,16 +127,25 @@ export function PutOnSheet({ ); } -/** Add on it: the room's things on nothing yet, several at once, side by side on the holder. */ +/** + * Add on it: the room's things on nothing yet, several at once, side by side on the holder; and + * under "Not on the map yet" (#152) the room's things that aren't on the map, each with the shape + * it would get: picking one puts it on the map and on the holder in the same step. + */ export function AddOnSheet({ host, plan, + view = NO_THINGS, + chosenShape, roomName, onOpenChange, onAdd, }: { host: Item; plan: Plan; + /** The location's things, for those not on the map yet (#152). */ + view?: Pick; + chosenShape?: ChosenShape | undefined; roomName: string; onOpenChange: (open: boolean) => void; onAdd: (ids: string[]) => void; @@ -135,14 +154,22 @@ export function AddOnSheet({ const names = useModelNames(); const [query, setQuery] = useState(''); const [picked, setPicked] = useState>(new Set()); - const things = useMemo(() => addableOn(plan.items, host), [plan.items, host]); + const onMap = useMemo(() => addableOn(plan.items, host), [plan.items, host]); + // biome-ignore lint/correctness/useExhaustiveDependencies: the shapes chosen don't change while it is open + const offMap = useMemo(() => offMapFor(plan, view, host, chosenShape), [plan, view, host]); + const total = onMap.length + offMap.length; const max = capacityOf(host.model); const limited = Number.isFinite(max); const left = roomOn(plan.items, host); const full = limited && left <= 0; - const rows = things.filter((c) => matches(query, c.name, c.shortCode)); + const find = (c: Item) => matches(query, c.name, c.shortCode); + const rowsOn = onMap.filter(find); + const rowsOff = offMap.filter(find); + const rows = [...rowsOn, ...rowsOff]; // An arm takes no more than its places: the rest can't be picked once they are taken. const disabled = limited && picked.size >= left ? rows.filter((c) => !picked.has(c.id)) : []; + const sub = (c: Item) => + c.shortCode ? `${names[c.model]} · ${printedCode(c.shortCode)}` : names[c.model]; const n = picked.size; const onNow = childrenOn(plan.items, host.id) .map((c) => c.name) @@ -166,7 +193,7 @@ export function AddOnSheet({ ) : null} {full ? null : ( <> - {things.length > SEARCH_FROM ? ( + {total > SEARCH_FROM ? ( (

- {things.length ? ( + {total ? ( Nothing matches. ) : carryOf(host.model) === 'arm' ? ( - No monitor in {roomName} is free. Put one on the map first. + No monitor in {roomName} is free. ) : ( - - Everything small in {roomName} is on something, or not on the map yet. - + Everything small in {roomName} is on something already. )}

)} > - {rows.map((c) => ( - - ))} + {[ + ...(rowsOn.length + ? [ + + {rowsOff.length ? ( +
+ On the map +
+ ) : null} + {rowsOn.map((c) => ( + + ))} +
, + ] + : []), + ...(rowsOff.length + ? [ + +
+ Not on the map yet +
+ {rowsOff.map((c) => ( + + ))} +
, + ] + : []), + ]}