Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions web/src/components/lantern/AreaNames.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VueWrapper> {
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')!
Expand Down
92 changes: 40 additions & 52 deletions web/src/components/lantern/AreaNames.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
clearOfPanels,
clearOfWords,
hangName,
keepMarksApart,
layoutNames,
markInside,
nameAt,
placeNames,
pressAt,
visiblePart,
type HungName,
type NamedSpot,
type PlacedName,
type WayPress,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand All @@ -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<Hung | null>(() => {
const focus = props.focused
? spots.value.find((s) => s.areaId === props.focused && s.leftOff)
: undefined
Expand Down Expand Up @@ -483,7 +453,7 @@ const pointed = computed<{
</template>
</TransitionGroup>
<svg
v-if="spots.some((spot) => spot.leader)"
v-if="spots.some((spot) => spot.leader) || pointed?.leader"
class="lq-area-lines"
:width="view.width"
:height="view.height"
Expand All @@ -495,8 +465,17 @@ const pointed = computed<{
:y1="spot.leader.y1"
:x2="spot.leader.x2"
:y2="spot.leader.y2"
:class="{ 'lq-area-faded': pointed?.covers.includes(spot.areaId) }"
/>
</template>
<line
v-if="pointed?.leader"
:x1="pointed.leader.x1"
:y1="pointed.leader.y1"
:x2="pointed.leader.x2"
:y2="pointed.leader.y2"
data-area-pointer-line
/>
</svg>
<TransitionGroup name="lq-area">
<span
Expand All @@ -508,6 +487,7 @@ const pointed = computed<{
{
'lq-area-way': spot.direction,
'lq-area-live': showing && !spot.direction,
'lq-area-faded': pointed?.covers.includes(spot.areaId),
},
]"
:data-area-name="spot.areaId"
Expand Down Expand Up @@ -608,6 +588,14 @@ const pointed = computed<{
color: var(--lq-accent-text);
border-color: var(--lq-accent-line);
}
.lq-area-faded {
opacity: 0;
pointer-events: none;
}
.lq-area-name,
.lq-area-lines line {
transition: opacity calc(0.2s * var(--lq-motion)) ease;
}
.lq-area-enter-active,
.lq-area-leave-active {
transition: opacity calc(0.2s * var(--lq-motion)) ease;
Expand Down
78 changes: 78 additions & 0 deletions web/src/lantern/areaNames.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Rect } from './roomMotionScene'
import {
clearOfPanels,
clearOfWords,
hangName,
keepMarksApart,
closedWayThings,
layoutNames,
Expand Down Expand Up @@ -1300,3 +1301,80 @@ describe('keepMarksApart with marks on the words', () => {
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()
})
})
Loading
Loading