diff --git a/web/src/components/lantern/AreaNames.test.ts b/web/src/components/lantern/AreaNames.test.ts index f8ea3adb6..49a1d7cb9 100644 --- a/web/src/components/lantern/AreaNames.test.ts +++ b/web/src/components/lantern/AreaNames.test.ts @@ -782,6 +782,80 @@ describe('AreaNames', () => { expect(w.get('[data-area-pointer]').text()).toBe('Tilda') }) + describe('a revealed name never covers a shown name (rq-eyan)', () => { + const shelf = { + areaId: 'shelf', + name: 'shelf', + label: 'Shelves', + kind: 'scenery' as const, + } + /** The rectangle of a drawn name element. */ + const rectOf = (el: Element): [number, number, number, number] => { + const style = el.getAttribute('style')! + const n = (k: string): number => + parseFloat(new RegExp(`${k}: ([\\d.]+)px`).exec(style)![1]) + const label = el.textContent!.length * 7.4 + 22 + return [n('left'), n('top'), n('left') + label, n('top') + 25] + } + const hit = (a: number[], b: number[]): boolean => + a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3] + + async function crowded( + label = 'Grandmother Tilda the Elder', + ): Promise { + const w = await boxedIn({ + things: [ + ...things.map((t) => + t.areaId === 'villager' ? { ...t, label } : t, + ), + shelf, + ], + boxes: { + ...boxes, + shelf: { x: 0.2, y: 0.52, width: 0.06, height: 0.06 }, + }, + }) + return w + } + + it('leaves every laid-out name unmoved and unobscured when a left-off name is revealed', async () => { + const w = await crowded() + const before = w + .findAll('.lq-area-name') + .map((e) => e.attributes('style')) + await w.setProps({ pointer: { x: 144, y: 270 } }) + const pointer = rectOf(w.get('[data-area-pointer]').element) + for (const el of w.findAll('[data-area-name]')) { + const faded = el.classes().includes('lq-area-faded') + expect(faded || !hit(pointer, rectOf(el.element))).toBe(true) + } + expect( + w.findAll('[data-area-name]').map((e) => e.attributes('style')), + ).toEqual(before.filter((st) => !st?.includes('pointer'))) + }) + + it('keeps the dots of faded names lit and brings the names back when the pointer leaves', async () => { + const w = await crowded() + await w.setProps({ pointer: { x: 144, y: 270 } }) + expect(w.findAll('[data-area-mark]').length).toBeGreaterThan(1) + await w.setProps({ pointer: null }) + expect(w.find('.lq-area-faded').exists()).toBe(false) + expect(w.find('[data-area-pointer]').exists()).toBe(false) + }) + + it('fades the names it must cover, with their lines, while it shows, and brings them back after', async () => { + // A name as wide as the strip between the plate and the card: nothing near is clear, so it lies over Shelves. + const w = await crowded('Tilda '.repeat(25).trim()) + await w.setProps({ pointer: { x: 144, y: 270 } }) + const shelves = w.get('[data-area-name="shelf"]') + expect(shelves.classes()).toContain('lq-area-faded') + expect(w.find('[data-area-mark="shelf"]').exists()).toBe(true) + expect(w.find('.lq-area-lines line.lq-area-faded').exists()).toBe(true) + await w.setProps({ pointer: null }) + expect(w.find('.lq-area-faded').exists()).toBe(false) + }) + }) + /** The pointer name's bottom edge (its height is NAME_HEIGHT, 25) lies above the card at 285. */ const clearOfCard = (w: VueWrapper): void => { const style = w.get('[data-area-pointer]').attributes('style')! diff --git a/web/src/components/lantern/AreaNames.vue b/web/src/components/lantern/AreaNames.vue index 01d270626..dc20671db 100644 --- a/web/src/components/lantern/AreaNames.vue +++ b/web/src/components/lantern/AreaNames.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { clearOfPanels, clearOfWords, + hangName, keepMarksApart, layoutNames, markInside, @@ -10,6 +11,7 @@ import { placeNames, pressAt, visiblePart, + type HungName, type NamedSpot, type PlacedName, type WayPress, @@ -89,11 +91,6 @@ const LETTER = 7.4 const NAME_HEIGHT = 25 /** How long the words take to fade away (a little over), before what they covered is clear. */ const SETTLE_MS = 350 -/** How far under the pointer its name hangs, and how near the foot of what can be seen it hangs above instead. */ -const POINTER_GAP = 18 -const FOOT = 60 -/** The room a hung name keeps round it from a card or panel when it can. */ -const HANG_ROOM = 3 /** How near a dot the pointer must be to point at it. */ const DOT_HIT = 14 /** The things in the stage that a name keeps clear of. */ @@ -381,53 +378,31 @@ function press(spot: NamedSpot, event: MouseEvent): void { } /** - * A name hung at (`x`, `y`): under it, or above it near the foot of what can be seen. Where that spot lies on a - * card or a panel (a dot boxed in between the room plate and the cards), the other side of the dot is tried, then - * beside it (nudged up or down to fit a narrow gap), so the name is not hung behind the words. With no clear - * spot it hangs where it lies least on cards and panels. + * A name hung at (`x`, `y`): under it, or above it near the foot of what can be seen, in the first spot clear of + * the cards, panels and every name already showing (`hangName`); with none beside it, the nearest clear spot with a + * line back to the dot; with none at all it hangs beside the dot and the names it lies over fade back (`covers`). */ function hangAt( entry: { label: string; kind: string }, x: number, y: number, -): { label: string; kind: string; left: number; top: number } { - const { w, h } = sizeOf(entry.label) - const { x: left, y: top, w: across, h: down } = visible.value - const above = y > top + down - FOOT - const pin = (l: number, t: number): { left: number; top: number } => ({ - left: Math.max(left, Math.min(left + across - w, l)), - top: Math.max(top, Math.min(top + down - h, t)), - }) - const centred = x - w / 2 - const aboveTop = y - POINTER_GAP - h - const belowTop = y + POINTER_GAP - const beside = y - h / 2 - // Slid in steps of 2px so a gap only a little taller than the name is still found. - const slides = [0, 2, -2, 4, -4, 6, -6, 8, -8, 10, -10, 12, -12] - const tries = [ - pin(centred, above ? aboveTop : belowTop), - pin(centred, above ? belowTop : aboveTop), - ...slides.map((d) => pin(x + POINTER_GAP, beside + d)), - ...slides.map((d) => pin(x - POINTER_GAP - w, beside + d)), - ] - // How much of the name, grown by `pad` on every side, lies on a card or panel. - const covered = (at: { left: number; top: number }, pad: number): number => - obstacles.value.reduce((sum, o) => { - const across = - Math.min(at.left + w + pad, o.x + o.w) - Math.max(at.left - pad, o.x) - const down = - Math.min(at.top + h + pad, o.y + o.h) - Math.max(at.top - pad, o.y) - return across > 0 && down > 0 ? sum + across * down : sum - }, 0) - // The first spot clear with a little room round it; else the first clear edge to edge (a narrow gap must still - // fit the name); else the one that lies least on cards and panels. - const best = - tries.find((t) => covered(t, HANG_ROOM) === 0) ?? - tries.find((t) => covered(t, 0) === 0) ?? - tries.reduce((a, b) => (covered(b, 0) < covered(a, 0) ? b : a)) - return { label: entry.label, kind: entry.kind, ...best } +): Hung { + const hung = hangName( + sizeOf(entry.label), + { x, y }, + obstacles.value, + named.value.map((s) => ({ + areaId: s.areaId, + rect: { x: s.left, y: s.top, w: s.width, h: s.height }, + })), + visible.value, + ) + return { label: entry.label, kind: entry.kind, ...hung } } +/** A revealed name with its place, the line back to its dot and the shown names it lies over. */ +type Hung = { label: string; kind: string } & HungName + /** * The one name that hangs by the pointer: the thing it is over, when not * everything is shown (or, while everything is shown, when that thing's name is @@ -436,12 +411,7 @@ function hangAt( * named the same way, under its dot, and while it is the focused thing's name * that is shown the pointer adds none (one name hangs at a time). */ -const pointed = computed<{ - label: string - kind: string - left: number - top: number -} | null>(() => { +const pointed = computed(() => { const focus = props.focused ? spots.value.find((s) => s.areaId === props.focused && s.leftOff) : undefined @@ -483,7 +453,7 @@ const pointed = computed<{ + { for (const m of out) expect(m.x).toBeLessThanOrEqual(40) }) }) + +describe('hangName', () => { + const visible: Rect = { x: 0, y: 0, w: 600, h: 400 } + const size = { w: 60, h: 25 } + const at = (r: Rect): Rect => r + const hits = (a: Rect, b: Rect): boolean => + a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h + const box = (h: { left: number; top: number }): Rect => ({ + x: h.left, + y: h.top, + w: size.w, + h: size.h, + }) + + it('hangs just below the dot, with no line, when nothing is in the way', () => { + const hung = hangName(size, { x: 200, y: 100 }, [], [], visible) + expect(hung.leader).toBeNull() + expect(hung.covers).toEqual([]) + expect(hung.top).toBeGreaterThan(100) + }) + + it('takes a spot clear of a name already showing, with a line back to its dot', () => { + const shown = [ + { areaId: 'shelves', rect: at({ x: 160, y: 110, w: 100, h: 25 }) }, + ] + const hung = hangName(size, { x: 200, y: 100 }, [], shown, visible) + expect(hits(box(hung), shown[0].rect)).toBe(false) + expect(hung.covers).toEqual([]) + expect(hung.leader === null || hung.leader.x1 === 200).toBe(true) + }) + + it('draws a line when it strays from the dot to find the spot', () => { + const wall: Rect[] = [ + { x: 100, y: 50, w: 200, h: 40 }, + { x: 100, y: 108, w: 200, h: 40 }, + ] + const shown = [{ areaId: 'a', rect: at({ x: 0, y: 0, w: 1, h: 1 }) }] + const hung = hangName(size, { x: 200, y: 100 }, wall, shown, visible) + expect(hits(box(hung), wall[0])).toBe(false) + expect(hits(box(hung), wall[1])).toBe(false) + expect(hung.leader).not.toBeNull() + }) + + it('beside the dot, and names the shown names it covers, when no spot is clear', () => { + const everything: Rect = { x: 0, y: 0, w: 600, h: 400 } + const shown = [ + { areaId: 'shelves', rect: at({ x: 150, y: 105, w: 100, h: 25 }) }, + { areaId: 'far', rect: at({ x: 500, y: 300, w: 60, h: 25 }) }, + ] + const hung = hangName( + size, + { x: 200, y: 100 }, + [everything], + shown, + visible, + ) + expect(hung.covers).toEqual(['shelves']) + }) + + it('never strays past the reach or draws its line across a card', () => { + const plate: Rect = { x: 0, y: 0, w: 600, h: 250 } + const card: Rect = { x: 0, y: 285, w: 600, h: 215 } + const strip = [ + { areaId: 'l', rect: at({ x: 0, y: 250, w: 190, h: 35 }) }, + { areaId: 'r', rect: at({ x: 210, y: 250, w: 390, h: 35 }) }, + ] + const hung = hangName( + size, + { x: 200, y: 270 }, + [plate, card], + strip, + visible, + ) + expect(hung.top + size.h).toBeLessThanOrEqual(285 + 1) + expect(hung.leader).toBeNull() + }) +}) diff --git a/web/src/lantern/areaNames.ts b/web/src/lantern/areaNames.ts index 491ec4060..dcf110d93 100644 --- a/web/src/lantern/areaNames.ts +++ b/web/src/lantern/areaNames.ts @@ -763,7 +763,7 @@ function ringOffsets(d: number, w: number): Array<[number, number]> { /** The line from the mark to the nearest point of its name's rectangle. */ function leaderFor( - entry: PlacedName, + entry: { x: number; y: number }, rect: Rect, ): { x1: number; y1: number; x2: number; y2: number } { return { @@ -773,3 +773,102 @@ function leaderFor( y2: Math.max(rect.y, Math.min(rect.y + rect.h, entry.y)), } } + +/** How far under the dot a revealed name hangs, and how near the foot of what can be seen it hangs above instead. */ +const HANG_GAP = 18 +const HANG_FOOT = 60 +/** The room a revealed name keeps round it from a card, panel or name when it can. */ +const HANG_ROOM = 3 + +/** Where a revealed name goes: its corner, the line back to its dot when it strays from it, and the shown names it lies over. */ +export interface HungName { + left: number + top: number + leader: { x1: number; y1: number; x2: number; y2: number } | null + /** The `areaId`s of the shown `names` this name lies over (they fade back while it shows); empty when it is clear. */ + covers: string[] +} + +/** + * Where a name of `size` revealed at (`dot`) hangs. It takes the first spot + * beside the dot (under it, or above it near the foot of the `visible` part, + * then either side) that is clear of the `obstacles` (cards and panels) and of + * every shown name in `names`; failing that the nearest clear spot round the + * dot, with a line back to it; failing that the spot beside the dot that lies + * least on the obstacles, and the names it lies over are listed in `covers`. + */ +export function hangName( + size: { w: number; h: number }, + dot: { x: number; y: number }, + obstacles: readonly Rect[], + names: ReadonlyArray<{ areaId: string; rect: Rect }>, + visible: Rect, +): HungName { + const { w, h } = size + const { x: left, y: top, w: across, h: down } = visible + const pin = (l: number, t: number): { left: number; top: number } => ({ + left: clamp(l, left, left + across - w), + top: clamp(t, top, top + down - h), + }) + const above = dot.y > top + down - HANG_FOOT + const centred = dot.x - w / 2 + const aboveTop = dot.y - HANG_GAP - h + const belowTop = dot.y + HANG_GAP + const beside = dot.y - h / 2 + // Slid in steps of 2px so a gap only a little taller than the name is still found. + const slides = [0, 2, -2, 4, -4, 6, -6, 8, -8, 10, -10, 12, -12] + const tries = [ + pin(centred, above ? aboveTop : belowTop), + pin(centred, above ? belowTop : aboveTop), + ...slides.map((d) => pin(dot.x + HANG_GAP, beside + d)), + ...slides.map((d) => pin(dot.x - HANG_GAP - w, beside + d)), + ] + const lie = ( + at: { left: number; top: number }, + pad: number, + rs: readonly Rect[], + ): number => + rs.reduce((sum, o) => { + const x = + Math.min(at.left + w + pad, o.x + o.w) - Math.max(at.left - pad, o.x) + const y = + Math.min(at.top + h + pad, o.y + o.h) - Math.max(at.top - pad, o.y) + return x > 0 && y > 0 ? sum + x * y : sum + }, 0) + const boxes = [...obstacles, ...names.map((n) => n.rect)] + const clear = (at: { left: number; top: number }, pad: number): boolean => + lie(at, pad, boxes) === 0 + const near = + tries.find((t) => clear(t, HANG_ROOM)) ?? tries.find((t) => clear(t, 0)) + if (near) return { ...near, leader: null, covers: [] } + const wanted = pin(centred, above ? aboveTop : belowTop) + for (let ring = 1; ring <= RINGS; ring++) { + const side = ring * STEP + w / 2 + // The ring's own spots, then either side level with the dot (where a line runs straight to it). + const offsets: Array<[number, number]> = [ + ...ringOffsets(ring * STEP, w), + [side, beside - wanted.top], + [-side, beside - wanted.top], + ] + for (const [dx, dy] of offsets) { + const at = pin(wanted.left + dx, wanted.top + dy) + if (!clear(at, HANG_ROOM)) continue + const rect = { x: at.left, y: at.top, w, h } + const leader = leaderFor(dot, rect) + // Near the dot, with a line that crosses no card, panel or name. + if ( + Math.hypot(leader.x2 - dot.x, leader.y2 - dot.y) > NAME_REACH || + boxes.some((b) => crosses(leader, b)) + ) + continue + return { ...at, leader, covers: [] } + } + } + const best = tries.reduce((a, b) => + lie(b, 0, obstacles) < lie(a, 0, obstacles) ? b : a, + ) + const covers = names + .filter((n) => overlaps({ x: best.left, y: best.top, w, h }, n.rect)) + .map((n) => n.areaId) + return { ...best, leader: null, covers } +}