diff --git a/web/src/components/lantern/AreaNames.test.ts b/web/src/components/lantern/AreaNames.test.ts index 6778d59a9..44336fb46 100644 --- a/web/src/components/lantern/AreaNames.test.ts +++ b/web/src/components/lantern/AreaNames.test.ts @@ -105,6 +105,29 @@ function cardsOver( at(cardEl, card) } +/** The place of a thing's dot (the layer's pixels), or null when it has none. */ +function markAt(w: VueWrapper, id: string): { x: number; y: number } | null { + const el = w.find(`[data-area-mark="${id}"]`) + if (!el.exists()) return null + const style = el.attributes('style') ?? '' + return { + x: parseFloat(/left: ([\d.-]+)px/.exec(style)![1]), + y: parseFloat(/top: ([\d.-]+)px/.exec(style)![1]), + } +} + +/** Whether `y` is at the top or the foot of a 540px picture, where words that fill it end. */ +function onAnEdge(y: number): boolean { + return y <= 20 || y >= 520 +} + +/** Let the words' measuring and fading settle. */ +async function settle(w: VueWrapper, relayout: string): Promise { + await w.setProps({ relayout }) + await new Promise((resolve) => setTimeout(resolve, 400)) + await nextTick() +} + beforeEach(() => { setActivePinia(createPinia()) vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(960) @@ -393,8 +416,10 @@ describe('AreaNames', () => { await w.setProps({ relayout: 'words' }) await new Promise((resolve) => setTimeout(resolve, 400)) await nextTick() - expect(w.find('[data-area-mark="villager"]').exists()).toBe(true) - expect(w.find('[data-area-mark="bucket"]').exists()).toBe(false) + expect(markAt(w, 'villager')).toEqual({ x: 432, y: 270 }) + // The bucket's point is under the words, which fill the height: its dot stands on their edge at its own x. + expect(markAt(w, 'bucket')?.x).toBe(144) + expect(markAt(w, 'bucket')!.y).toBeGreaterThan(520) }) /** @@ -432,20 +457,19 @@ describe('AreaNames', () => { } it.each(['lq-story-wrap', 'lq-arrival-card', 'lq-ln'])( - 'has neither mark nor name for a thing under %s, until it goes', + 'keeps a dot for a thing under %s, on the words’ edge at its own x, until they go', async (selector) => { const w = mountNames({ showing: true }) const words = wordsOver(selector) await w.setProps({ relayout: 'words' }) await new Promise((resolve) => setTimeout(resolve, 400)) await nextTick() - expect(w.find('[data-area-mark="villager"]').exists()).toBe(false) - expect(w.find('[data-area-mark="bucket"]').exists()).toBe(true) + expect(markAt(w, 'villager')?.x).toBe(144) + expect(onAnEdge(markAt(w, 'villager')!.y)).toBe(true) + expect(markAt(w, 'bucket')).toEqual({ x: 816, y: 405 }) words.remove() - await w.setProps({ relayout: 'gone' }) - await new Promise((resolve) => setTimeout(resolve, 400)) - await nextTick() - expect(w.find('[data-area-mark="villager"]').exists()).toBe(true) + await settle(w, 'gone') + expect(markAt(w, 'villager')).toEqual({ x: 144, y: 270 }) }, ) @@ -457,19 +481,21 @@ describe('AreaNames', () => { await new Promise((resolve) => setTimeout(resolve, 400)) await nextTick() expect(w.find('[data-area-mark="villager"]').exists()).toBe(true) - // The way north's mark (480, 54) is under the card. - expect(w.find('[data-area-mark="exit-north"]').exists()).toBe(false) + // The way north's mark (480, 54) is under the card: it steps just above it. + const north = markAt(w, 'exit-north')! + expect(north.x).toBe(480) + expect(north.y).toBeLessThan(20) }) - it('has neither mark nor name for a thing under a spoken line of the one column (rq-a6o6)', async () => { + it('steps the dot of a thing under a spoken line of the one column out from under it (rq-a6o6, rq-qz71)', async () => { const w = mountNames({ showing: true }) // A spoken line's bubble (20..240) in a column running to 320; the villager's mark (144, 270) is below it. cardsOver([0, 320], [0, 320], [20, 240], true) await w.setProps({ relayout: 'words' }) await new Promise((resolve) => setTimeout(resolve, 400)) await nextTick() - expect(w.find('[data-area-mark="exit-north"]').exists()).toBe(false) - expect(w.find('[data-area-mark="villager"]').exists()).toBe(true) + expect(markAt(w, 'exit-north')?.y).toBeLessThan(20) + expect(markAt(w, 'villager')).toEqual({ x: 144, y: 270 }) }) it('shows a thing above the story where a card has scrolled out of it (rq-a6o6)', async () => { @@ -501,12 +527,103 @@ describe('AreaNames', () => { }) wrappers.push(w) await nextTick() - expect(w.find('[data-area-mark="villager"]').exists()).toBe(false) + expect(onAnEdge(markAt(w, 'villager')!.y)).toBe(true) // Faded, with nothing else changing. words.style.visibility = 'hidden' await new Promise((resolve) => setTimeout(resolve, 400)) await nextTick() - expect(w.find('[data-area-mark="villager"]').exists()).toBe(true) + expect(markAt(w, 'villager')).toEqual({ x: 144, y: 270 }) + }) + }) + + describe('covered things (rq-qz71)', () => { + it('stands the dot of a thing under a card just below it, and a press on it is a press on its thing', async () => { + const w = mountNames({ showing: true }) + // One card 20..300 across the width; the villager's dot (144, 270) is under it. + cardsOver([0, 540], [0, 540], [20, 300]) + await settle(w, 'words') + const dot = markAt(w, 'villager')! + expect(dot.x).toBe(144) + expect(dot.y).toBeGreaterThan(300) + expect(dot.y).toBeLessThan(330) + await w.get('[data-area-mark="villager"]').trigger('click') + expect(w.emitted('press')?.[0][0]).toEqual(things[0]) + }) + + it('names a stepped dot beside it when there is room', async () => { + const w = mountNames({ showing: true }) + cardsOver([0, 540], [0, 540], [20, 300]) + await settle(w, 'words') + expect(w.find('[data-area-name="villager"]').exists()).toBe(true) + }) + + it('keeps a stepped dot unnamed when the words leave no room, and names it on the pointer', async () => { + // The words fill all of the picture: no name fits near, the dot stands on their edge. + const w2 = mountNames({ showing: true }) + cardsOver([0, 540], [0, 540], [0, 540]) + await settle(w2, 'words') + expect(w2.find('[data-area-mark="villager"]').exists()).toBe(true) + expect(w2.find('[data-area-name="villager"]').exists()).toBe(false) + const dot = markAt(w2, 'villager')! + await w2.setProps({ pointer: { x: dot.x, y: dot.y } }) + expect(w2.get('[data-area-pointer]').text()).toBe('Tilda') + }) + + it('shows a focused covered thing’s dot and name at the stepped spot while not showing', async () => { + const w = mountNames({ showing: false, focused: 'villager' }) + cardsOver([0, 540], [0, 540], [20, 300]) + await settle(w, 'words') + expect(markAt(w, 'villager')!.y).toBeGreaterThan(300) + expect(w.find('[data-area-name="villager"]').exists()).toBe(true) + }) + + it('moves the dots back onto their things when the words go, and again when they come', async () => { + const w = mountNames({ showing: true }) + cardsOver([0, 540], [0, 540], [20, 300]) + await settle(w, 'words') + expect(markAt(w, 'villager')!.y).toBeGreaterThan(300) + document.querySelector('.lq-story-wrap')!.remove() + await settle(w, 'gone') + expect(markAt(w, 'villager')).toEqual({ x: 144, y: 270 }) + }) + + it('steps a dot under the plate up into the gap between the plate and the cards (the Shelves case)', async () => { + const w = mountNames({ showing: true }) + const plate = document.createElement('div') + plate.className = 'lq-stage-plate' + document.querySelector('.lq-stage')!.append(plate) + vi.spyOn(plate, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 300, + height: 60, + right: 300, + bottom: 60, + x: 0, + y: 0, + toJSON: () => ({}), + }) + // The exit's dot (480, 54) is clear of the plate in x; put the villager's (144, 270) under both: the gap 60..120 is above the card. + cardsOver([120, 540], [120, 540], [120, 400]) + await settle(w, 'words') + const dot = markAt(w, 'villager')! + expect(dot.x).toBe(144) + expect(dot.y).toBeGreaterThan(400) + }) + + it('keeps two covered dots apart', async () => { + const w = mountNames({ + showing: true, + boxes: { + ...boxes, + bucket: { x: 0.1, y: 0.4, width: 0.1, height: 0.2 }, + }, + }) + cardsOver([0, 540], [0, 540], [20, 300]) + await settle(w, 'words') + const a = markAt(w, 'villager')! + const b = markAt(w, 'bucket')! + expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThanOrEqual(23.5) }) }) diff --git a/web/src/components/lantern/AreaNames.vue b/web/src/components/lantern/AreaNames.vue index 8ab626449..272827a04 100644 --- a/web/src/components/lantern/AreaNames.vue +++ b/web/src/components/lantern/AreaNames.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { clearOfPanels, + clearOfWords, keepMarksApart, layoutNames, markInside, @@ -27,7 +28,9 @@ import type { PictureSize, Rect } from '@/lantern/roomMotionScene' * would cover another name or a button, a plate or the words moves to a free * spot with a thin line back to its mark, and a thing under the words (the * narrator's cards and spoken lines where they are seen, not the empty rest of - * their column) has neither until the words are gone. A mark whose spot lies under the room + * their column) has its mark stepped just outside them, straight above or + * below (on their nearest edge, half over them, where no free picture lies + * near), and hiding the words puts it back on its thing. A mark whose spot lies under the room * plate, a button in the picture's corner, the story's Hide/Show tab or the * Show what's here button steps to the nearest spot just outside that panel * (out from under the words when it can; its name and line follow it, and a @@ -230,17 +233,6 @@ watch( { flush: 'post' }, ) -/** Whether the mark of `entry` lies under the words. */ -function underWords(entry: PlacedName): boolean { - return covers.value.some( - (r) => - entry.x >= r.x && - entry.x <= r.x + r.w && - entry.y >= r.y && - entry.y <= r.y + r.h, - ) -} - /** The placed things in view of the picture. */ const inView = computed(() => { if (view.value.width < 1 || view.value.height < 1) return [] @@ -252,24 +244,28 @@ const visible = computed(() => visiblePart(view.value, props.footCover ?? 0), ) -/** The things in view with their marks stepped clear of the panels and kept apart from each other. */ -function settled(): PlacedName[] { - return keepMarksApart( - clearOfPanels( - markInside(inView.value, visible.value), +/** + * The placed things with a part that can be seen, their marks stepped clear of + * the panels and of the words (just outside them, or on their nearest edge + * where no free picture lies near) and kept apart from each other. + */ +const placed = computed(() => + keepMarksApart( + clearOfWords( + clearOfPanels( + markInside(inView.value, visible.value), + panels.value, + visible.value, + covers.value, + ), + covers.value, panels.value, visible.value, - covers.value, ), panels.value, visible.value, covers.value, - ) -} - -/** The placed things with a part that can be seen, their marks stepped clear of the panels, whose mark is not under the words. */ -const placed = computed(() => - settled().filter((entry) => !underWords(entry)), + ), ) /** The closed ways that are placed and in view. */ @@ -332,19 +328,7 @@ const spots = computed(() => { if (!props.showing && !props.focused) return [] const all = allSpots.value if (props.showing) return all - const own = all.filter((spot) => spot.areaId === props.focused) - if (own.length > 0) return own - // A focused thing whose mark is under the words is not in `placed`; it is still shown. - const focus = settled().find((entry) => entry.areaId === props.focused) - return focus - ? layoutNames( - [...placed.value, focus], - sizeOf, - obstacles.value, - visible.value, - wordsOpen.value, - ).filter((spot) => spot.areaId === props.focused) - : [] + return all.filter((spot) => spot.areaId === props.focused) }) /** The spots that have a name drawn (those not left off). */ diff --git a/web/src/lantern/areaNames.test.ts b/web/src/lantern/areaNames.test.ts index 61d50984f..db6c8fbcd 100644 --- a/web/src/lantern/areaNames.test.ts +++ b/web/src/lantern/areaNames.test.ts @@ -6,6 +6,7 @@ import type { AreaThing } from './areaThings' import type { Rect } from './roomMotionScene' import { clearOfPanels, + clearOfWords, keepMarksApart, closedWayThings, layoutNames, @@ -1091,3 +1092,151 @@ describe('markInside records where a moved mark would have stood', () => { expect(low.from).toEqual({ x: 100, y: 725 }) }) }) + +describe('clearOfWords', () => { + const visible = { x: 0, y: 0, w: 1000, h: 700 } + const mark = (x: number, y: number, areaId = 'a') => ({ + areaId, + name: areaId, + label: areaId, + kind: 'scenery' as const, + x, + y, + rect: { x: x - 20, y: y - 20, w: 40, h: 40 }, + }) + /** The words: a column of cards on the left, 300 wide. */ + const card = { x: 0, y: 100, w: 300, h: 200 } + + it('leaves a mark no words cover where it is', () => { + const [m] = clearOfWords([mark(500, 200)], [card], [], visible) + expect([m.x, m.y]).toEqual([500, 200]) + expect(m.from).toBeUndefined() + }) + + it('steps a mark under the words straight below them when that is nearer', () => { + const [m] = clearOfWords([mark(150, 250)], [card], [], visible) + expect(m.x).toBe(150) + expect(m.y).toBeGreaterThan(300) + expect(m.y).toBeLessThan(300 + 20) + expect(m.from).toEqual({ x: 150, y: 250 }) + expect(m.onWords).toBeUndefined() + }) + + it('steps a mark straight above them when that is nearer', () => { + const [m] = clearOfWords([mark(150, 130)], [card], [], visible) + expect(m.x).toBe(150) + expect(m.y).toBeLessThan(100) + expect(m.y).toBeGreaterThan(100 - 20) + }) + + it('goes on past a second card the first step would land under', () => { + const second = { x: 0, y: 300, w: 300, h: 100 } + const [m] = clearOfWords([mark(150, 250)], [card, second], [], visible) + expect(m.y).toBeGreaterThan(400) + }) + + it('does not land under a panel', () => { + const plate = { x: 0, y: 300, w: 300, h: 60 } + // Below is taken by the plate, so the mark goes above the card instead. + const [m] = clearOfWords([mark(150, 250)], [card], [plate], visible) + expect(m.y).toBeLessThan(100) + }) + + it('keeps the whole pressable rectangle', () => { + const [m] = clearOfWords([mark(150, 250)], [card], [], visible) + expect(m.rect).toEqual({ x: 130, y: 230, w: 40, h: 40 }) + }) + + it('stands on the nearest edge of the words, half over them, when no free picture lies above or below', () => { + // Words from 150 to 650 across the width, with a panel above and one below too close to step into. + const band = { x: 0, y: 150, w: 1000, h: 500 } + const above = { x: 0, y: 100, w: 1000, h: 40 } + const below = { x: 0, y: 660, w: 1000, h: 40 } + const [low] = clearOfWords( + [mark(500, 600)], + [band], + [above, below], + visible, + ) + expect([low.x, low.y]).toEqual([500, 650]) + expect(low.onWords).toBe(true) + const [high] = clearOfWords( + [mark(500, 200)], + [band], + [above, below], + visible, + ) + expect([high.x, high.y]).toEqual([500, 150]) + expect(high.onWords).toBe(true) + }) + + it('steps out the clear side, even when the other edge is nearer', () => { + const band = { x: 0, y: 150, w: 1000, h: 500 } + const plate = { x: 0, y: 100, w: 1000, h: 45 } + const below = { x: 0, y: 660, w: 1000, h: 40 } + const [m] = clearOfWords([mark(500, 200)], [band], [plate, below], visible) + // The plate bars the top and the other panel the foot: the dot stands on an edge. + expect(m.onWords).toBe(true) + const [n] = clearOfWords( + [mark(500, 200)], + [band], + [plate, { x: 0, y: 900, w: 1, h: 1 }], + visible, + ) + expect(n.y).toBe(662) + expect(n.onWords).toBeUndefined() + }) + + it('stands at the nearest edge that is not wholly inside the words, never 8px inside a card that reaches the foot', () => { + // The words reach the foot of the visible part: the other edge is the one that is half over them. + const column = { x: 0, y: 8, w: 300, h: 692 } + const [m] = clearOfWords([mark(150, 600)], [column], [], visible) + expect(m.y).toBe(8) + expect(m.onWords).toBe(true) + }) + + it('stands on the edge line of a card that fills the picture but for a sliver', () => { + const tall = { x: 0, y: 8, w: 1000, h: 692 } + const [m] = clearOfWords([mark(400, 300)], [tall], [], visible) + expect(m.onWords).toBe(true) + expect(m.y).toBe(8) + }) +}) + +describe('keepMarksApart with marks on the words', () => { + const visible = { x: 0, y: 0, w: 1000, h: 700 } + const wall = { x: 0, y: 0, w: 1000, h: 700 } + const on = (x: number, id: string) => ({ + areaId: id, + name: id, + label: id, + kind: 'scenery' as const, + x, + y: 692, + rect: { x: x - 20, y: 500, w: 40, h: 40 }, + from: { x, y: 600 }, + onWords: true as const, + }) + + it('keeps two marks standing on the edge apart, along the edge', () => { + const out = keepMarksApart([on(500, 'a'), on(505, 'b')], [], visible, [ + wall, + ]) + expect(out[0].y).toBe(692) + expect(out[1].y).toBe(692) + expect( + Math.hypot(out[0].x - out[1].x, out[0].y - out[1].y), + ).toBeGreaterThanOrEqual(23.5) + }) + + it('never slides a mark on the edge past the end of its words', () => { + const narrow = { x: 0, y: 0, w: 40, h: 700 } + const out = keepMarksApart( + [on(10, 'a'), on(14, 'b'), on(18, 'c')], + [], + visible, + [narrow], + ) + for (const m of out) expect(m.x).toBeLessThanOrEqual(40) + }) +}) diff --git a/web/src/lantern/areaNames.ts b/web/src/lantern/areaNames.ts index b736f7ba2..6075c57d5 100644 --- a/web/src/lantern/areaNames.ts +++ b/web/src/lantern/areaNames.ts @@ -22,6 +22,8 @@ export interface PlacedName extends AreaThing { point?: { x: number; y: number } /** Where the mark would have stood had it not been moved (off a panel, or in off the edge of what can be seen); absent when it was never moved. */ from?: { x: number; y: number } + /** True for a mark that found no free picture beside the words and stands on their nearest edge, half over them (`clearOfWords`). */ + onWords?: true } /** A name's place: its top-left corner and size, and the thin line back to its mark when it had to move (null when it sits by its mark). */ @@ -164,7 +166,7 @@ function underPanel(x: number, y: number, panel: Rect): boolean { ) } -/** Whether (`x`, `y`) lies under the words' rectangle `words`, where a mark waits unseen. */ +/** Whether (`x`, `y`) lies under the words' rectangle `words`, where a mark would be covered. */ function inWords(x: number, y: number, words: Rect): boolean { return ( x >= words.x && @@ -181,7 +183,7 @@ function inWords(x: number, y: number, words: Rect): boolean { * to either side, that is on the `visible` part (a spot that lands under another * panel goes on past it); a * panel that spans the width leaves only below or above. A spot under the - * `words` (where the mark would wait unseen) is taken only when no other is + * `words` (where the mark would be covered; `clearOfWords` moves it on) is taken only when no other is * clear. A mark under no panel stays; one with no such spot is left where it * is. `rect`, what can be pressed, stays whole, so a thing wholly under a * panel is still marked. @@ -258,6 +260,91 @@ export function clearOfPanels( }) } +/** + * The marks of `marks` that lie under the `words` (the narrator's cards and + * spoken lines in view) moved out from under them, so the thing keeps a dot + * that can be seen and pressed. Such a mark steps straight above or below the + * words to the nearest spot just outside them (`REACH` from their edge; a spot + * under further words goes on past them), on the `visible` part and under none + * of the `panels`; below wins a tie. Where neither side has such a spot the mark + * stands on the nearest edge of the words, straight above or below its thing, + * half over them, and is flagged `onWords`. `rect`, what can be pressed, + * stays whole; a mark under no words stays. + */ +export function clearOfWords( + marks: readonly PlacedName[], + words: readonly Rect[], + panels: readonly Rect[], + visible: Rect, +): PlacedName[] { + const left = visible.x + MARK_RADIUS + const right = visible.x + visible.w - MARK_RADIUS + const top = visible.y + MARK_RADIUS + const bottom = visible.y + visible.h - MARK_RADIUS + /** The edge of the words past `y` in the direction `dir` (1 below, -1 above), taking in every words rect it runs into. */ + const edgeOf = (x: number, y: number, dir: 1 | -1): number => { + let edge = y + for (let hop = 0; hop <= words.length; hop++) { + const next = words.find( + (r) => + x >= r.x - REACH && + x <= r.x + r.w + REACH && + (dir > 0 + ? r.y <= edge + REACH && r.y + r.h > edge + : r.y < edge && r.y + r.h >= edge - REACH), + ) + if (!next) break + edge = dir > 0 ? next.y + next.h : next.y + } + return edge + } + return marks.map((entry) => { + const { x, y } = entry + if (!words.some((r) => inWords(x, y, r))) return entry + const kx = clamp(x, left, right) + let best: number | null = null + /** The edges to stand on: how far the dot would lie inside the words there, and whether a panel covers it. */ + const stands: Array<{ y: number; depth: number; panel: boolean }> = [] + for (const dir of [1, -1] as const) { + const edge = edgeOf(x, y, dir) + const ky = clamp(edge + dir * REACH, top, bottom) + const clear = + !words.some((r) => inWords(kx, ky, r)) && + !panels.some((p) => underPanel(kx, ky, p)) && + Math.abs(edge + dir * REACH - ky) <= REACH + if (clear && (best === null || Math.abs(ky - y) < Math.abs(best - y))) + best = ky + const stand = clamp(edge, top, bottom) + stands.push({ + y: stand, + depth: Math.max(0, dir > 0 ? edge - stand : stand - edge), + panel: panels.some((p) => underPanel(kx, stand, p)), + }) + } + const from = entry.from ?? { x, y } + if (best !== null) return { ...entry, x: kx, y: best, from } + // No free picture: the edge that is half over the words at most and under no panel, nearest first. + const rank = (e: { + y: number + depth: number + panel: boolean + }): number[] => [ + Number(e.panel), + Number(e.depth > MARK_RADIUS / 2), + e.depth, + Math.abs(e.y - y), + ] + const stand = stands.reduce((a, b) => { + const ra = rank(a) + const rb = rank(b) + for (let i = 0; i < ra.length; i++) + if (ra[i] !== rb[i]) return ra[i] < rb[i] ? a : b + return a + }) + return { ...entry, x: kx, y: stand.y, from, onWords: true as const } + }) +} + /** How far apart two marks' middles stand: a mark's 12 px and as much again of clear space. */ const MARK_APART = 24 @@ -274,10 +361,11 @@ const MARK_APART = 24 * whole line is full onto a row one `MARK_APART` further from where it came * from (below the line, for a mark that stands on its thing). A spot must be on * the `visible` part, under none of the `panels` and not under the `words` - * (which hide a mark); a mark with no such spot keeps where it is. A mark + * (which would cover it); a mark with no such spot keeps where it is. A mark * stepped out vertically gives way level with the nearest mark it gives way * to when it can, first to the side its thing lies on (left when its thing - * lies left of that mark's thing, else right). + * lies left of that mark's thing, else right). A mark standing on the edge of + * the words (`onWords`) slides only along that edge, in the words' own band. */ export function keepMarksApart( marks: readonly PlacedName[], @@ -324,14 +412,18 @@ export function keepMarksApart( y >= top - 0.5 && y <= bottom + 0.5 && !panels.some((panel) => underPanel(x, y, panel)) && - !underWords(x, y) && + (self.onWords + ? Math.abs(y - self.y) < 0.5 && + words.some((r) => x >= r.x && x <= r.x + r.w) + : !underWords(x, y)) && placedAt.every(([px, py]) => Math.hypot(px - x, py - y) >= MARK_APART - 0.5) for (const { entry, index } of order) { - // A mark standing on its thing under the words is hidden with them: it + // A mark standing on its thing under the words (none, once `clearOfWords` has run) is covered with them: it // stays, and neither moves nor pushes another. if (!entry.from && underWords(entry.x, entry.y)) continue const from = entry.from ?? entry - const sideways = Math.abs(entry.x - from.x) > Math.abs(entry.y - from.y) + const sideways = + !entry.onWords && Math.abs(entry.x - from.x) > Math.abs(entry.y - from.y) // The line the mark slid along, and which way a further row lies. const along: [number, number] = sideways ? [0, 1] : [1, 0] const away: [number, number] = sideways @@ -342,7 +434,7 @@ export function keepMarksApart( // the nearest mark it gives way to, first on the side its thing lies on // (compared with that mark's thing), so the order of the things is kept; // every other mark slides right first, along its own line. - const stepped = !sideways && !!entry.from + const stepped = !sideways && !!entry.from && !entry.onWords const blocker = placedAt .filter( ([px, py]) => Math.hypot(px - entry.x, py - entry.y) < MARK_APART - 0.5,