mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 15:21:48 +03:00
parent
523a32471e
commit
7019de70b7
@ -0,0 +1,437 @@
|
||||
import {
|
||||
mouseDictatedPlacement,
|
||||
nonDictatedPlacement,
|
||||
previousNodeDictatedPlacement,
|
||||
type Environment,
|
||||
type Placement,
|
||||
} from '@/components/ComponentBrowser/placement'
|
||||
import * as iterable from '@/util/iterable'
|
||||
import { chain, map, range } from '@/util/iterable'
|
||||
import { Rect } from '@/util/rect'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import { fc, test as fcTest } from '@fast-check/vitest'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
|
||||
// Vue playground to visually inspect failing fuzz cases:
|
||||
// https://play.vuejs.org/#eNrNU09PwjAU/ypNNeGCMPFC5jRR40EPatSbNXGMxyiMtmnfYGbZd/e1Y0Ci4Wwv6+/Pa3+v7Wp+Y8xgXQKPeeIyKw0yB1iaa6EyrRwyCxk6dsU+hPoU6pAlsmYFzDBmUZ+hNuG7kVOcx+w8ovkcZD4neRSxRqhk2G5ASxNAWJkiRSCUTOWaOfwu4ErwfU0UmeryYD0PBSc/Y6FifTbTlipCFqnapIKzuFuqZkY7iVKrmPXSidNFidDrbzN/nda+YuBRY6qvbQsdTaBltwE6PsBW6aJ2UotbbZJmy9zqUk1p75NxGKNRj86BXydDir/v41/mHdEYj3/l3c6S4e76eJ+jo0cxk/lg4bSih1R7q+CZXhlZgH02viW6mZgFxWtpUejNY+DQltDv+GwO2fIPfuEqzwn+YsGBXYPgOw1TmwO28v3bE1Q034krPS0Lch8RXyGcNGVsbbd0CBT7wBfSPqyMtihV/u7uKwTluqZ8UO9sgl9w+pvujrS+j3sxuAh1QjW8+QFAeS2/
|
||||
|
||||
const defaultScreenBounds = new Rect(new Vec2(100, 200), new Vec2(2000, 1000))
|
||||
const size = new Vec2(100, 20)
|
||||
const radius = size.y / 2
|
||||
|
||||
const getScreenBounds = vi.fn(() => defaultScreenBounds)
|
||||
const getNodeRects = vi.fn(() => iterable.empty())
|
||||
const getGap = vi.fn(() => 24)
|
||||
const getSelectedNodeRects = vi.fn(() => iterable.empty())
|
||||
const getMousePosition = vi.fn(() => Vec2.Zero)
|
||||
// Center is at (1100, 700)
|
||||
const screenBounds = defaultScreenBounds
|
||||
// Half of this is (50, 10)
|
||||
const nodeSize = size
|
||||
|
||||
function rectAt(left: number, top: number) {
|
||||
return new Rect(new Vec2(left, top), size)
|
||||
}
|
||||
|
||||
function rectAtX(left: number) {
|
||||
return (top: number) => rectAt(left, top)
|
||||
}
|
||||
|
||||
function rectAtY(top: number) {
|
||||
return (left: number) => rectAt(left, top)
|
||||
}
|
||||
|
||||
function nonDictatedEnvironment(nodeRects: Iterable<Rect>): Environment {
|
||||
return {
|
||||
screenBounds,
|
||||
nodeRects,
|
||||
get selectedNodeRects() {
|
||||
return getSelectedNodeRects()
|
||||
},
|
||||
get mousePosition() {
|
||||
return getMousePosition()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test.each([
|
||||
// === Miscellaneous tests ===
|
||||
// Empty graph
|
||||
{ nodes: [], pos: new Vec2(1050, 690) },
|
||||
|
||||
// === Single node tests ===
|
||||
// Single node
|
||||
{ nodes: [rectAt(1050, 690)], pos: new Vec2(1050, 734) },
|
||||
// Single node (far enough left that it does not overlap)
|
||||
{ nodes: [rectAt(950, 690)], pos: new Vec2(1050, 690) },
|
||||
// Single node (far enough right that it does not overlap)
|
||||
{ nodes: [rectAt(1150, 690)], pos: new Vec2(1050, 690) },
|
||||
// Single node (overlaps on the left by 1px)
|
||||
{ nodes: [rectAt(951, 690)], pos: new Vec2(1050, 734) },
|
||||
// Single node (overlaps on the right by 1px)
|
||||
{ nodes: [rectAt(1149, 690)], pos: new Vec2(1050, 734) },
|
||||
// Single node (BIG gap)
|
||||
{ nodes: [rectAt(1050, 690)], gap: 1000, pos: new Vec2(1050, 1710), pan: new Vec2(0, 1020) },
|
||||
|
||||
// === Multiple node tests ===
|
||||
// Multiple nodes
|
||||
{ nodes: map(range(0, 1001, 20), rectAtX(1050)), pos: new Vec2(1050, 1044) },
|
||||
// Multiple nodes with gap
|
||||
{ nodes: map(range(1000, -1, -20), rectAtX(1050)), pos: new Vec2(1050, 1044) },
|
||||
{
|
||||
nodes: chain(
|
||||
map(range(500, 901, 20), rectAtX(1050)),
|
||||
map(range(1000, 1501, 20), rectAtX(1050)),
|
||||
),
|
||||
pos: new Vec2(1050, 944),
|
||||
},
|
||||
// Multiple nodes with gap (just big enough)
|
||||
{ nodes: map(range(690, 1500, 88), rectAtX(1050)), pos: new Vec2(1050, 734) },
|
||||
// Multiple nodes with gap (slightly too small)
|
||||
{ nodes: map(range(500, 849, 87), rectAtX(1050)), pos: new Vec2(1050, 892) },
|
||||
// Multiple nodes with smallest gap
|
||||
{
|
||||
nodes: chain(map(range(500, 901, 20), rectAtX(1050)), map(range(988, 1489, 20), rectAtX(1050))),
|
||||
pos: new Vec2(1050, 944),
|
||||
},
|
||||
// Multiple nodes with smallest gap (reverse)
|
||||
{
|
||||
nodes: chain(
|
||||
map(range(1488, 987, -20), rectAtX(1050)),
|
||||
map(range(900, 499, -20), rectAtX(1050)),
|
||||
),
|
||||
pos: new Vec2(1050, 944),
|
||||
},
|
||||
// Multiple nodes with gap that is too small
|
||||
{
|
||||
nodes: chain(map(range(500, 901, 20), rectAtX(1050)), map(range(987, 1488, 20), rectAtX(1050))),
|
||||
// This gap is 1px smaller than the previous test - so, 1px too small.
|
||||
// This position is offscreen (y >= 1000), so we pan so that the new node is centered (1531 - 690).
|
||||
pos: new Vec2(1050, 1531),
|
||||
pan: new Vec2(0, 841),
|
||||
},
|
||||
// Multiple nodes with gap that is too small (each range reversed)
|
||||
{
|
||||
nodes: chain(
|
||||
map(range(900, 499, -20), rectAtX(1050)),
|
||||
map(range(1487, 986, -20), rectAtX(1050)),
|
||||
),
|
||||
pos: new Vec2(1050, 1531),
|
||||
pan: new Vec2(0, 841),
|
||||
},
|
||||
])('Non dictated placement', ({ nodes, pos, gap, pan }) => {
|
||||
expect(nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes), gap ? { gap } : {})).toEqual(
|
||||
{ position: pos, pan },
|
||||
)
|
||||
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
fcTest.prop({
|
||||
nodeData: fc.array(
|
||||
fc.record({
|
||||
left: fc.nat(1000),
|
||||
top: fc.nat(1000),
|
||||
width: fc.nat(1000),
|
||||
height: fc.nat(1000),
|
||||
}),
|
||||
{ minLength: 15, maxLength: 25 },
|
||||
),
|
||||
})('Non dictated placement (prop testing)', ({ nodeData }) => {
|
||||
const nodes = nodeData.map(
|
||||
({ left, top, width, height }) => new Rect(new Vec2(left, top), new Vec2(width, height)),
|
||||
)
|
||||
const newNodeRect = new Rect(
|
||||
nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes)).position,
|
||||
nodeSize,
|
||||
)
|
||||
for (const node of nodes) {
|
||||
expect(node.intersects(newNodeRect), {
|
||||
toString() {
|
||||
return generateVueCodeForNonDictatedPlacement(newNodeRect, nodes)
|
||||
},
|
||||
} as string).toBe(false)
|
||||
}
|
||||
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
function previousNodeDictatedEnvironment(nodeRects: Rect[]): Environment {
|
||||
return {
|
||||
screenBounds,
|
||||
nodeRects,
|
||||
selectedNodeRects: nodeRects.slice(-1),
|
||||
get mousePosition() {
|
||||
return getMousePosition()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('Previous node dictated placement throws when there are no selected nodes', () => {
|
||||
expect(() =>
|
||||
previousNodeDictatedPlacement(nodeSize, previousNodeDictatedEnvironment([])),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test.each([
|
||||
// === Single node tests ===
|
||||
// Single node
|
||||
{ nodes: [], pos: new Vec2(1050, 734) },
|
||||
// Single node (far enough up that it does not overlap)
|
||||
{ nodes: [rectAt(1150, 714)], pos: new Vec2(1050, 734) },
|
||||
// Single node (far enough down that it does not overlap)
|
||||
{ nodes: [rectAt(1150, 754)], pos: new Vec2(1050, 734) },
|
||||
// Single node (far enough left that it does not overlap)
|
||||
{ nodes: [rectAt(926, 734)], pos: new Vec2(1050, 734) },
|
||||
// Single node (overlapping on the left by 1px)
|
||||
{ nodes: [rectAt(927, 734)], pos: new Vec2(1051, 734) },
|
||||
// Single node (blocking initial position)
|
||||
{ nodes: [rectAt(1050, 734)], pos: new Vec2(1174, 734) },
|
||||
// Single node (far enough right that it does not overlap)
|
||||
{ nodes: [rectAt(1174, 690)], pos: new Vec2(1050, 734) },
|
||||
// Single node (overlapping on the right by 1px)
|
||||
{ nodes: [rectAt(1173, 734)], pos: new Vec2(1297, 734) },
|
||||
// Single node (overlaps on the top by 1px)
|
||||
{ nodes: [rectAt(1050, 715)], pos: new Vec2(1174, 734) },
|
||||
// Single node (overlaps on the bottom by 1px)
|
||||
{ nodes: [rectAt(1050, 753)], pos: new Vec2(1174, 734) },
|
||||
// Single node (BIG gap)
|
||||
{ nodes: [], gap: 1000, pos: new Vec2(1050, 1710), pan: new Vec2(0, 1020) },
|
||||
// Single node (BIG gap, overlapping on the left by 1px)
|
||||
{
|
||||
nodes: [rectAt(927, 1710)],
|
||||
gap: 1000,
|
||||
pos: new Vec2(2027, 1710),
|
||||
pan: new Vec2(977, 1020),
|
||||
},
|
||||
|
||||
// === Multiple node tests ===
|
||||
// Multiple nodes
|
||||
{
|
||||
nodes: map(range(1000, 2001, 100), rectAtY(734)),
|
||||
pos: new Vec2(2124, 734),
|
||||
pan: new Vec2(1074, 44),
|
||||
},
|
||||
// Multiple nodes (reverse)
|
||||
{
|
||||
nodes: map(range(2000, 999, -100), rectAtY(734)),
|
||||
pos: new Vec2(2124, 734),
|
||||
pan: new Vec2(1074, 44),
|
||||
},
|
||||
// Multiple nodes with gap
|
||||
{
|
||||
nodes: chain(
|
||||
map(range(1000, 1401, 100), rectAtY(734)),
|
||||
map(range(1700, 2001, 100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(1524, 734),
|
||||
},
|
||||
// Multiple nodes with gap (just big enough)
|
||||
{
|
||||
nodes: map(range(1050, 2000, 248), rectAtY(734)),
|
||||
pos: new Vec2(1174, 734),
|
||||
},
|
||||
// Multiple nodes with gap (slightly too small)
|
||||
{
|
||||
nodes: map(range(1050, 1792, 247), rectAtY(734)),
|
||||
pos: new Vec2(1915, 734),
|
||||
},
|
||||
// Multiple nodes with smallest gap
|
||||
{
|
||||
nodes: chain(
|
||||
map(range(1000, 1401, 100), rectAtY(734)),
|
||||
map(range(1648, 1949, 100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(1524, 734),
|
||||
},
|
||||
// Multiple nodes with smallest gap (reverse)
|
||||
{
|
||||
nodes: chain(
|
||||
map(range(1948, 1647, -100), rectAtY(734)),
|
||||
map(range(1400, 999, -100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(1524, 734),
|
||||
},
|
||||
// Multiple nodes with gap that is too small
|
||||
{
|
||||
nodes: chain(
|
||||
map(range(1000, 1401, 100), rectAtY(734)),
|
||||
map(range(1647, 1948, 100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(2071, 734),
|
||||
pan: new Vec2(1021, 44),
|
||||
},
|
||||
// Multiple nodes with gap that is too small (each range reversed)
|
||||
{
|
||||
nodes: chain(
|
||||
map(range(1400, 999, -100), rectAtY(734)),
|
||||
map(range(1947, 1646, -100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(2071, 734),
|
||||
pan: new Vec2(1021, 44),
|
||||
},
|
||||
])('Previous node dictated placement', ({ nodes, gap, pos, pan }) => {
|
||||
expect(
|
||||
previousNodeDictatedPlacement(
|
||||
nodeSize,
|
||||
previousNodeDictatedEnvironment([...nodes, rectAt(1050, 690)]),
|
||||
gap != null ? { gap } : {},
|
||||
),
|
||||
).toEqual({ position: pos, pan })
|
||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
fcTest.prop({
|
||||
nodeData: fc.array(
|
||||
fc.record({
|
||||
left: fc.nat(1000),
|
||||
top: fc.nat(1000),
|
||||
width: fc.nat(1000),
|
||||
height: fc.nat(1000),
|
||||
}),
|
||||
{ minLength: 15, maxLength: 25 },
|
||||
),
|
||||
firstSelectedNode: fc.integer({ min: 7, max: 12 }),
|
||||
})('Previous node dictated placement (prop testing)', ({ nodeData, firstSelectedNode }) => {
|
||||
const nodeRects = nodeData.map(
|
||||
({ left, top, width, height }) => new Rect(new Vec2(left, top), new Vec2(width, height)),
|
||||
)
|
||||
const selectedNodeRects = nodeRects.slice(firstSelectedNode)
|
||||
const newNodeRect = new Rect(
|
||||
previousNodeDictatedPlacement(nodeSize, {
|
||||
screenBounds,
|
||||
nodeRects,
|
||||
selectedNodeRects,
|
||||
get mousePosition() {
|
||||
return getMousePosition()
|
||||
},
|
||||
}).position,
|
||||
nodeSize,
|
||||
)
|
||||
expect(newNodeRect.top, {
|
||||
toString() {
|
||||
return generateVueCodeForPreviousNodeDictatedPlacement(
|
||||
newNodeRect,
|
||||
nodeRects,
|
||||
selectedNodeRects,
|
||||
)
|
||||
},
|
||||
} as string).toBeGreaterThanOrEqual(Math.max(...selectedNodeRects.map((node) => node.bottom)))
|
||||
for (const node of nodeRects) {
|
||||
expect(node.intersects(newNodeRect), {
|
||||
toString() {
|
||||
return generateVueCodeForPreviousNodeDictatedPlacement(
|
||||
newNodeRect,
|
||||
nodeRects,
|
||||
selectedNodeRects,
|
||||
)
|
||||
},
|
||||
} as string).toBe(false)
|
||||
}
|
||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
fcTest.prop({
|
||||
x: fc.nat(1000),
|
||||
y: fc.nat(1000),
|
||||
})('Mouse dictated placement (prop testing)', ({ x, y }) => {
|
||||
expect(
|
||||
mouseDictatedPlacement(
|
||||
nodeSize,
|
||||
{
|
||||
mousePosition: new Vec2(x, y),
|
||||
get screenBounds() {
|
||||
return getScreenBounds()
|
||||
},
|
||||
get nodeRects() {
|
||||
return getNodeRects()
|
||||
},
|
||||
get selectedNodeRects() {
|
||||
return getSelectedNodeRects()
|
||||
},
|
||||
},
|
||||
{
|
||||
get gap() {
|
||||
return getGap()
|
||||
},
|
||||
},
|
||||
),
|
||||
).toEqual<Placement>({
|
||||
// Note: Currently, this is a reimplementation of the entire mouse dictated placement algorithm.
|
||||
position: new Vec2(x - radius, y - radius),
|
||||
})
|
||||
// Non-overlap test omitted, as mouse-dictated node placement MAY overlap existing nodes.
|
||||
expect(getScreenBounds, 'Should not depend on `screenBounds`').not.toHaveBeenCalled()
|
||||
expect(getNodeRects, 'Should not depend on `nodeRects`').not.toHaveBeenCalled()
|
||||
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
||||
expect(getGap, 'Should not depend on `gap`').not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// === Helpers for debugging ===
|
||||
|
||||
function generateVueCodeForNonDictatedPlacement(newNode: Rect, rects: Rect[]) {
|
||||
return `Please visually inspect the code below at https://play.vuejs.org/:
|
||||
<script setup>
|
||||
const rects = [
|
||||
${rects
|
||||
.map(
|
||||
(rect) =>
|
||||
` { left: ${rect.left}, top: ${rect.top}, width: (${rect.right} - ${rect.left}), height: (${rect.bottom} - ${rect.top}) },`,
|
||||
)
|
||||
.join('\n')}
|
||||
]
|
||||
|
||||
const rect = { left: ${newNode.pos.x}, top: ${newNode.pos.y}, width: ${newNode.size.x}, height: ${
|
||||
newNode.size.y
|
||||
} }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="height: 1000px; width: 2000px;">
|
||||
<div v-for="rect in rects" :style="{ position: 'absolute', left: rect.left + 'px', top: rect.top + 'px', width: rect.width + 'px', height: rect.height + 'px', background: '#88888822' }"></div>
|
||||
<div :style="{ position: 'absolute', left: rect.left + 'px', top: rect.top + 'px', width: rect.width + 'px', height: rect.height + 'px', background: '#88222288' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
`
|
||||
}
|
||||
|
||||
function generateVueCodeForPreviousNodeDictatedPlacement(
|
||||
newNode: Rect,
|
||||
rects: Rect[],
|
||||
selectedRects: Rect[],
|
||||
) {
|
||||
return `Please visually inspect the code below at https://play.vuejs.org/:
|
||||
<script setup>
|
||||
const rects = [
|
||||
${rects
|
||||
.filter((rect) => !selectedRects.includes(rect))
|
||||
.map(
|
||||
(rect) =>
|
||||
` { left: ${rect.left}, top: ${rect.top}, width: (${rect.right} - ${rect.left}), height: (${rect.bottom} - ${rect.top}) },`,
|
||||
)
|
||||
.join('\n')}
|
||||
]
|
||||
|
||||
const selectedRects = [
|
||||
${selectedRects
|
||||
.map(
|
||||
(rect) =>
|
||||
` { left: ${rect.left}, top: ${rect.top}, width: (${rect.right} - ${rect.left}), height: (${rect.bottom} - ${rect.top}) },`,
|
||||
)
|
||||
.join('\n')}
|
||||
]
|
||||
|
||||
const rect = { left: ${newNode.pos.x}, top: ${newNode.pos.y}, width: ${newNode.size.x}, height: ${
|
||||
newNode.size.y
|
||||
} }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="height: 1000px; width: 2000px;">
|
||||
<div v-for="rect in rects" :style="{ position: 'absolute', left: rect.left + 'px', top: rect.top + 'px', width: rect.width + 'px', height: rect.height + 'px', background: '#88888822' }"></div>
|
||||
<div v-for="rect in selectedRects" :style="{ position: 'absolute', left: rect.left + 'px', top: rect.top + 'px', width: rect.width + 'px', height: rect.height + 'px', background: '#4444aa44' }"></div>
|
||||
<div :style="{ position: 'absolute', left: rect.left + 'px', top: rect.top + 'px', width: rect.width + 'px', height: rect.height + 'px', background: '#88222288' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
`
|
||||
}
|
@ -9,7 +9,7 @@ import { compareOpt } from '@/util/compare'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
import { isSome } from '@/util/opt'
|
||||
import { qnIsTopElement, qnLastSegmentIndex } from '@/util/qualifiedName'
|
||||
import type { Range } from '@/util/range'
|
||||
import { Range } from '@/util/range'
|
||||
|
||||
interface ComponentLabel {
|
||||
label: string
|
||||
@ -51,16 +51,15 @@ export function labelOfEntry(
|
||||
range.end <= lastSegmentStart
|
||||
? []
|
||||
: [
|
||||
{
|
||||
start: Math.max(0, range.start - lastSegmentStart),
|
||||
end: range.end - lastSegmentStart,
|
||||
},
|
||||
new Range(
|
||||
Math.max(0, range.start - lastSegmentStart),
|
||||
range.end - lastSegmentStart,
|
||||
),
|
||||
],
|
||||
),
|
||||
...(match.nameRanges ?? []).map((range) => ({
|
||||
start: range.start + nameOffset,
|
||||
end: range.end + nameOffset,
|
||||
})),
|
||||
...(match.nameRanges ?? []).map(
|
||||
(range) => new Range(range.start + nameOffset, range.end + nameOffset),
|
||||
),
|
||||
],
|
||||
}
|
||||
} else
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry'
|
||||
import type { Opt } from '@/util/opt'
|
||||
import { qnIsTopElement, qnParent, type QualifiedName } from '@/util/qualifiedName'
|
||||
import type { Range } from '@/util/range'
|
||||
import { Range } from '@/util/range'
|
||||
|
||||
export interface Filter {
|
||||
pattern?: string
|
||||
@ -94,7 +94,7 @@ class FilteringWithPattern {
|
||||
for (let i = 1, pos = 0; i < wordMatch.length; i += 1) {
|
||||
// Matches come in groups of three, and the first matched part is `match[2]`.
|
||||
if (i % 3 === 2) {
|
||||
result.push({ start: pos, end: pos + wordMatch[i]!.length })
|
||||
result.push(new Range(pos, pos + wordMatch[i]!.length))
|
||||
}
|
||||
pos += wordMatch[i]!.length
|
||||
}
|
||||
@ -106,7 +106,7 @@ class FilteringWithPattern {
|
||||
for (let i = 1, pos = 0; i < initialsMatch.length; i += 1) {
|
||||
// Matches come in groups of two, and the first matched part is `match[2]` (= 0 mod 2).
|
||||
if (i % 2 === 0) {
|
||||
result.push({ start: pos, end: pos + initialsMatch[i]!.length })
|
||||
result.push(new Range(pos, pos + initialsMatch[i]!.length))
|
||||
}
|
||||
pos += initialsMatch[i]!.length
|
||||
}
|
||||
@ -202,7 +202,7 @@ class FilteringQualifiedName {
|
||||
for (let i = 1, pos = 0; i < match.length; i += 1) {
|
||||
// Matches come in groups of two, and the first matched part is `match[2]` (= 0 mod 2).
|
||||
if (i % 2 === 0) {
|
||||
result.push({ start: pos, end: pos + match[i]!.length })
|
||||
result.push(new Range(pos, pos + match[i]!.length))
|
||||
}
|
||||
pos += match[i]!.length
|
||||
}
|
||||
|
151
app/gui2/src/components/ComponentBrowser/placement.ts
Normal file
151
app/gui2/src/components/ComponentBrowser/placement.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { bail } from '@/util/assert'
|
||||
import { MultiRange, Range } from '@/util/range'
|
||||
import { Rect } from '@/util/rect'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
|
||||
export interface Environment {
|
||||
screenBounds: Rect
|
||||
selectedNodeRects: Iterable<Rect>
|
||||
nodeRects: Iterable<Rect>
|
||||
mousePosition: Vec2
|
||||
}
|
||||
|
||||
export interface PlacementOptions {
|
||||
gap?: number
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
position: Vec2
|
||||
pan?: Vec2
|
||||
}
|
||||
|
||||
// The default gap is the height of a single node.
|
||||
const defaultGap = 24
|
||||
|
||||
/** The new node should appear at the center of the screen if there is enough space for the new node.
|
||||
* Otherwise, it should be moved down to the closest free space.
|
||||
*
|
||||
* Specifically, this code, in order:
|
||||
* - uses the center of the screen as the initial position
|
||||
* - searches for all vertical spans below the initial position, that horizontally intersect the
|
||||
* initial position (no horizontal gap is required between the new node and old nodes)
|
||||
* - shifts the node down (if required) until there is sufficient vertical space -
|
||||
* the height of the node, in addition to the specified gap both above and below the node.
|
||||
*
|
||||
* [Documentation](https://github.com/enso-org/design/blob/main/epics/component-browser/design.md#placement-of-newly-opened-component-browser) */
|
||||
export function nonDictatedPlacement(
|
||||
nodeSize: Vec2,
|
||||
{ screenBounds, nodeRects }: Environment,
|
||||
{ gap = defaultGap }: PlacementOptions = {},
|
||||
): Placement {
|
||||
const initialPosition = screenBounds.center().sub(nodeSize.scale(0.5))
|
||||
const initialRect = new Rect(initialPosition, nodeSize)
|
||||
let top = initialPosition.y
|
||||
const height = nodeSize.y
|
||||
const minimumVerticalSpace = height + gap * 2
|
||||
const bottom = () => top + height
|
||||
const occupiedYRanges = new MultiRange()
|
||||
for (const rect of nodeRects) {
|
||||
if (initialRect.intersectsX(rect) && rect.bottom + gap > top) {
|
||||
if (rect.top - bottom() >= gap) {
|
||||
const range = new Range(rect.top, rect.bottom)
|
||||
occupiedYRanges.insert(range, range.expand(gap))
|
||||
} else {
|
||||
top = rect.bottom + gap
|
||||
const rangeIncludingTop = occupiedYRanges
|
||||
.remove(new Range(-Infinity, rect.bottom + minimumVerticalSpace))
|
||||
.at(-1)
|
||||
if (rangeIncludingTop) {
|
||||
top = Math.max(top, rangeIncludingTop.end + gap)
|
||||
occupiedYRanges.remove(rangeIncludingTop)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalPosition = new Vec2(initialPosition.x, top)
|
||||
if (new Rect(finalPosition, nodeSize).within(screenBounds)) return { position: finalPosition }
|
||||
else return { position: finalPosition, pan: finalPosition.sub(initialPosition) }
|
||||
}
|
||||
|
||||
/** The new node should be left aligned to the first selected node (order of selection matters).
|
||||
* The Panel should also be placed vertically directly below the lowest of all selected nodes.
|
||||
*
|
||||
* If there is not enough empty space, the Expression Input Panel should be moved right
|
||||
* to the first empty place and the Magnet Alignment algorithm should be performed horizontally.
|
||||
* In case the place is offscreen, the camera should be panned accordingly.
|
||||
*
|
||||
* Specifically, this code, in order:
|
||||
* - uses the left side of the first selected node and as the initial x-position
|
||||
* - uses the lowest (highest y-position) of all selected nodes, plus the specified gap,
|
||||
* as the initial y-position
|
||||
* - searches for all horizontal spans to the right of the initial position,
|
||||
* that vertically intersect the initial position
|
||||
* (no vertical gap is required between the new node and old nodes)
|
||||
* - shifts the node right (if required) until there is sufficient horizontal space -
|
||||
* the width of the node, in addition to the specified gap to the left and right of the node.
|
||||
*
|
||||
* Note that the algorithm for finding free space is almost the same as for non-dictated placement,
|
||||
* except it searches horizontally instead of vertically.
|
||||
*
|
||||
* [Documentation](https://github.com/enso-org/design/blob/main/epics/component-browser/design.md#placement-of-newly-opened-component-browser) */
|
||||
export function previousNodeDictatedPlacement(
|
||||
nodeSize: Vec2,
|
||||
{ screenBounds, selectedNodeRects, nodeRects }: Environment,
|
||||
{ gap = defaultGap }: PlacementOptions = {},
|
||||
): Placement {
|
||||
let initialLeft: number | undefined
|
||||
let top = -Infinity
|
||||
for (const rect of selectedNodeRects) {
|
||||
initialLeft ??= rect.left
|
||||
const newTop = rect.bottom + gap
|
||||
if (newTop > top) top = newTop
|
||||
}
|
||||
if (initialLeft == null)
|
||||
bail('There are no selected nodes, so placement cannot be dictated by the previous node.')
|
||||
let left = initialLeft
|
||||
const width = nodeSize.x
|
||||
const right = () => left + width
|
||||
const minimumHorizontalSpace = width + gap * 2
|
||||
const initialPosition = new Vec2(left, top)
|
||||
const initialRect = new Rect(initialPosition, nodeSize)
|
||||
const occupiedXRanges = new MultiRange()
|
||||
for (const rect of nodeRects) {
|
||||
if (initialRect.intersectsY(rect) && rect.right + gap > left) {
|
||||
if (rect.left - right() >= gap) {
|
||||
const range = new Range(rect.left, rect.right)
|
||||
occupiedXRanges.insert(range, range.expand(gap))
|
||||
} else {
|
||||
left = rect.right + gap
|
||||
const rangeIncludingLeft = occupiedXRanges
|
||||
.remove(new Range(-Infinity, rect.right + minimumHorizontalSpace))
|
||||
.at(-1)
|
||||
if (rangeIncludingLeft) {
|
||||
left = Math.max(left, rangeIncludingLeft.end + gap)
|
||||
occupiedXRanges.remove(rangeIncludingLeft)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalPosition = new Vec2(left, top)
|
||||
if (new Rect(finalPosition, nodeSize).within(screenBounds)) return { position: finalPosition }
|
||||
else {
|
||||
const screenCenter = screenBounds.center().sub(nodeSize.scale(0.5))
|
||||
return { position: finalPosition, pan: finalPosition.sub(screenCenter) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The new node should appear exactly below the mouse.
|
||||
*
|
||||
* Specifically, this code assumes the node is fully rounded on the left and right sides,
|
||||
* so it subtracts half the node height (assumed to be the node radius) from the mouse x and y
|
||||
* positions.
|
||||
*
|
||||
* [Documentation](https://github.com/enso-org/design/blob/main/epics/component-browser/design.md#placement-of-newly-opened-component-browser) */
|
||||
export function mouseDictatedPlacement(
|
||||
nodeSize: Vec2,
|
||||
{ mousePosition }: Environment,
|
||||
_opts?: PlacementOptions,
|
||||
): Placement {
|
||||
const nodeRadius = nodeSize.y / 2
|
||||
return { position: mousePosition.sub(new Vec2(nodeRadius, nodeRadius)) }
|
||||
}
|
@ -26,7 +26,7 @@ const navigator = provideGraphNavigator(viewportNode)
|
||||
const graphStore = useGraphStore()
|
||||
const projectStore = useProjectStore()
|
||||
const componentBrowserVisible = ref(false)
|
||||
const componentBrowserPosition = ref(Vec2.Zero())
|
||||
const componentBrowserPosition = ref(Vec2.Zero)
|
||||
const suggestionDb = useSuggestionDbStore()
|
||||
|
||||
const nodeSelection = provideGraphSelection(navigator, graphStore.nodeRects, {
|
||||
|
@ -156,7 +156,7 @@ function circleIntersection(x: number, r1: number, r2: number): number {
|
||||
* connecting them, and the length of the target attachment bit.
|
||||
*/
|
||||
function junctionPoints(inputs: Inputs): JunctionPoints | null {
|
||||
let halfSourceSize = inputs.sourceSize?.scale(0.5) ?? Vec2.Zero()
|
||||
let halfSourceSize = inputs.sourceSize?.scale(0.5) ?? Vec2.Zero
|
||||
// The maximum x-distance from the source (our local coordinate origin) for the point where the
|
||||
// edge will begin.
|
||||
const sourceMaxXOffset = Math.max(halfSourceSize.x - NODE_CORNER_RADIUS, 0)
|
||||
@ -310,7 +310,7 @@ function pathElements(junctions: JunctionPoints): { start: Vec2; elements: Eleme
|
||||
}
|
||||
}
|
||||
const start = junctions.points[0]
|
||||
if (start == null) return { start: Vec2.Zero(), elements: [] }
|
||||
if (start == null) return { start: Vec2.Zero, elements: [] }
|
||||
let prev = start
|
||||
junctions.points.slice(1).map((j, i) => {
|
||||
const d = j.sub(prev)
|
||||
@ -387,7 +387,7 @@ function lengthTo(pos: Vec2): number | undefined {
|
||||
for (let i = 0; i < totalLength + precision; i += precision) {
|
||||
const len = Math.min(i, totalLength)
|
||||
const p = path.getPointAtLength(len)
|
||||
const dist = pos.distanceSquare(new Vec2(p.x, p.y))
|
||||
const dist = pos.distanceSquared(new Vec2(p.x, p.y))
|
||||
if (bestDist == null || dist < bestDist) {
|
||||
best = len
|
||||
bestDist = dist
|
||||
@ -396,7 +396,7 @@ function lengthTo(pos: Vec2): number | undefined {
|
||||
if (best == null || bestDist == null) return undefined
|
||||
const tryPos = (len: number) => {
|
||||
const point = path.getPointAtLength(len)
|
||||
const dist: number = pos.distanceSquare(new Vec2(point.x, point.y))
|
||||
const dist: number = pos.distanceSquared(new Vec2(point.x, point.y))
|
||||
if (bestDist == null || dist < bestDist) {
|
||||
best = len
|
||||
bestDist = dist
|
||||
|
@ -309,7 +309,7 @@ const editableKeydownHandler = nodeEditBindings.handler({
|
||||
|
||||
const startEpochMs = ref(0)
|
||||
let startEvent: PointerEvent | null = null
|
||||
let startPos = Vec2.Zero()
|
||||
let startPos = Vec2.Zero
|
||||
|
||||
const dragPointer = usePointer((pos, event, type) => {
|
||||
emit('movePosition', pos.delta)
|
||||
@ -325,7 +325,7 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
if (
|
||||
Number(new Date()) - startEpochMs.value <= MAXIMUM_CLICK_LENGTH_MS &&
|
||||
startEvent != null &&
|
||||
pos.absolute.distanceSquare(startPos) <= MAXIMUM_CLICK_DISTANCE_SQ
|
||||
pos.absolute.distanceSquared(startPos) <= MAXIMUM_CLICK_DISTANCE_SQ
|
||||
) {
|
||||
nodeSelection?.handleSelectionOf(startEvent, new Set([nodeId.value]))
|
||||
menuVisible.value = true
|
||||
|
@ -375,7 +375,7 @@ function nodeFromAst(ast: AstExtended<Ast.Tree>): Node {
|
||||
outerExprId: ast.astId,
|
||||
binding: ast.map((t) => t.pattern).repr(),
|
||||
rootSpan: ast.map((t) => t.expr),
|
||||
position: Vec2.Zero(),
|
||||
position: Vec2.Zero,
|
||||
vis: undefined,
|
||||
}
|
||||
} else {
|
||||
@ -383,7 +383,7 @@ function nodeFromAst(ast: AstExtended<Ast.Tree>): Node {
|
||||
outerExprId: ast.astId,
|
||||
binding: '',
|
||||
rootSpan: ast,
|
||||
position: Vec2.Zero(),
|
||||
position: Vec2.Zero,
|
||||
vis: undefined,
|
||||
}
|
||||
}
|
||||
|
32
app/gui2/src/util/__tests__/array.test.ts
Normal file
32
app/gui2/src/util/__tests__/array.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { partitionPoint } from '@/util/array'
|
||||
import { fc, test as fcTest } from '@fast-check/vitest'
|
||||
import { expect } from 'vitest'
|
||||
|
||||
const isEven = (n: number) => n % 2 === 0
|
||||
const isOdd = (n: number) => n % 2 === 1
|
||||
|
||||
fcTest.prop({
|
||||
evens: fc.array(fc.nat(1_000_000_000)).map((a) => a.map((n) => n * 2)),
|
||||
odds: fc.array(fc.nat(1_000_000_000)).map((a) => a.map((n) => n * 2 + 1)),
|
||||
})('partitionPoint (even/odd)', ({ evens, odds }) => {
|
||||
expect(partitionPoint([...evens, ...odds], isEven)).toEqual(evens.length)
|
||||
expect(partitionPoint([...odds, ...evens], isOdd)).toEqual(odds.length)
|
||||
})
|
||||
|
||||
fcTest.prop({
|
||||
arr: fc
|
||||
.array(fc.float())
|
||||
.map((a) => ({ arr: a.sort((a, b) => a - b), i: Math.floor(Math.random() * a.length) })),
|
||||
})('partitionPoint (ascending)', ({ arr: { arr, i } }) => {
|
||||
const target = arr[i]!
|
||||
expect(partitionPoint(arr, (n) => n < target)).toEqual(i)
|
||||
})
|
||||
|
||||
fcTest.prop({
|
||||
arr: fc
|
||||
.array(fc.float())
|
||||
.map((a) => ({ arr: a.sort((a, b) => b - a), i: Math.floor(Math.random() * a.length) })),
|
||||
})('partitionPoint (descending)', ({ arr: { arr, i } }) => {
|
||||
const target = arr[i]!
|
||||
expect(partitionPoint(arr, (n) => n > target)).toEqual(i)
|
||||
})
|
45
app/gui2/src/util/__tests__/range.test.ts
Normal file
45
app/gui2/src/util/__tests__/range.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { MultiRange, Range } from '@/util/range'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
function r(...r: [start: number, end: number][]) {
|
||||
return r.map(({ 0: start, 1: end }) => new Range(start, end))
|
||||
}
|
||||
|
||||
function mr(...r: [start: number, end: number][]) {
|
||||
const m = new MultiRange()
|
||||
for (const range of r) {
|
||||
m.insert(new Range(range[0], range[1]))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
function add(m: MultiRange, ...r: [start: number, end: number][]) {
|
||||
for (const range of r) {
|
||||
m.insert(new Range(range[0], range[1]))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
function sub(m: MultiRange, ...r: [start: number, end: number][]) {
|
||||
for (const range of r) {
|
||||
m.remove(new Range(range[0], range[1]))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
test('MultiRange', () => {
|
||||
expect(mr([0, 10], [10, 20]).ranges).toEqual(r([0, 20]))
|
||||
expect(mr([0, 8], [5, 15], [12, 20]).ranges).toEqual(r([0, 20]))
|
||||
expect(mr([0, 8], [12, 20], [5, 15]).ranges).toEqual(r([0, 20]))
|
||||
expect(mr([0, 8], [5, 15], [12, 20]).ranges).toEqual(r([0, 20]))
|
||||
expect(mr([0, 8], [12, 20]).ranges).toEqual(r([0, 8], [12, 20]))
|
||||
expect(mr([12, 20], [0, 8]).ranges).toEqual(r([0, 8], [12, 20]))
|
||||
expect(sub(mr([12, 20], [0, 8]), [5, 15]).ranges).toEqual(r([0, 5], [15, 20]))
|
||||
expect(add(sub(mr([12, 20], [0, 8]), [5, 15]), [12, 20]).ranges).toEqual(r([0, 5], [12, 20]))
|
||||
expect(add(sub(mr([12, 20], [0, 8]), [5, 15]), [12, 15]).ranges).toEqual(r([0, 5], [12, 20]))
|
||||
expect(add(sub(mr([12, 20], [0, 8]), [5, 15]), [12, 14]).ranges).toEqual(
|
||||
r([0, 5], [12, 14], [15, 20]),
|
||||
)
|
||||
expect(sub(mr([0, 20]), [-Infinity, 0]).ranges).toEqual(r([0, 20]))
|
||||
expect(sub(mr([0, 20]), [-Infinity, 5]).ranges).toEqual(r([5, 20]))
|
||||
})
|
@ -6,3 +6,31 @@ export function findIndexOpt<T>(arr: T[], pred: (elem: T) => boolean): number |
|
||||
const index = arr.findIndex(pred)
|
||||
return index >= 0 ? index : null
|
||||
}
|
||||
|
||||
/** Returns the index of the partition point according to the given predicate
|
||||
* (the index of the first element of the second partition).
|
||||
*
|
||||
* The array is assumed to be partitioned according to the given predicate.
|
||||
* This means that all elements for which the predicate returns `true` are at the start of the array
|
||||
* and all elements for which the predicate returns `false` are at the end.
|
||||
* For example, `[7, 15, 3, 5, 4, 12, 6]` is partitioned under the predicate `x % 2 != 0`
|
||||
* (all odd numbers are at the start, all even at the end).
|
||||
*
|
||||
* If this array is not partitioned, the returned result is unspecified and meaningless,
|
||||
* as this method performs a kind of binary search.
|
||||
*
|
||||
* @see The original docs for the equivalent function in Rust: {@link https://doc.rust-lang.org/std/primitive.slice.html#method.partition_point} */
|
||||
export function partitionPoint<T>(
|
||||
array: T[],
|
||||
pred: (elem: T) => boolean,
|
||||
start = 0,
|
||||
end = array.length,
|
||||
): number {
|
||||
while (start < end) {
|
||||
// Shift right by one to halve and round down in the same step.
|
||||
const middle = (start + end) >> 1
|
||||
if (pred(array[middle]!)) start = middle + 1
|
||||
else end = middle
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
@ -138,7 +138,7 @@ export function useResizeObserver(
|
||||
elementRef: Ref<Element | undefined | null>,
|
||||
useContentRect = true,
|
||||
): Ref<Vec2> {
|
||||
const sizeRef = shallowRef<Vec2>(Vec2.Zero())
|
||||
const sizeRef = shallowRef<Vec2>(Vec2.Zero)
|
||||
if (typeof ResizeObserver === 'undefined') return sizeRef
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
let rect: { width: number; height: number } | null = null
|
||||
|
32
app/gui2/src/util/iterable.ts
Normal file
32
app/gui2/src/util/iterable.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export function* empty(): Generator<never> {}
|
||||
|
||||
export function* range(start: number, stop: number, step = start <= stop ? 1 : -1) {
|
||||
if ((step > 0 && start > stop) || (step < 0 && start < stop)) {
|
||||
throw new Error(
|
||||
"The range's step is in the wrong direction - please use Infinity or -Infinity as the endpoint for an infinite range.",
|
||||
)
|
||||
}
|
||||
if (start <= stop) {
|
||||
while (start < stop) {
|
||||
yield start
|
||||
start += step
|
||||
}
|
||||
} else {
|
||||
while (start > stop) {
|
||||
yield start
|
||||
start += step
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* map<T, U>(iter: Iterable<T>, map: (value: T) => U) {
|
||||
for (const value of iter) {
|
||||
yield map(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function* chain<T>(...iters: Iterable<T>[]) {
|
||||
for (const iter of iters) {
|
||||
yield* iter
|
||||
}
|
||||
}
|
@ -8,13 +8,13 @@ function elemRect(target: Element | undefined): Rect {
|
||||
const domRect = target.getBoundingClientRect()
|
||||
return new Rect(new Vec2(domRect.x, domRect.y), new Vec2(domRect.width, domRect.height))
|
||||
}
|
||||
return Rect.Zero()
|
||||
return Rect.Zero
|
||||
}
|
||||
|
||||
export type NavigatorComposable = ReturnType<typeof useNavigator>
|
||||
export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
const size = useResizeObserver(viewportNode)
|
||||
const center = ref<Vec2>(Vec2.Zero())
|
||||
const center = ref<Vec2>(Vec2.Zero)
|
||||
const scale = ref(1)
|
||||
const panPointer = usePointer((pos) => {
|
||||
center.value = center.value.addScaled(pos.delta, -1 / scale.value)
|
||||
@ -34,7 +34,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
)
|
||||
}
|
||||
|
||||
let zoomPivot = Vec2.Zero()
|
||||
let zoomPivot = Vec2.Zero
|
||||
const zoomPointer = usePointer((pos, _event, ty) => {
|
||||
if (ty === 'start') {
|
||||
zoomPivot = clientToScenePos(pos.initial)
|
||||
|
@ -1,12 +1,9 @@
|
||||
export interface Range {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
import { partitionPoint } from '@/util/array'
|
||||
|
||||
export interface RangeWithMatch {
|
||||
start: number
|
||||
end: number
|
||||
isMatch: boolean
|
||||
readonly start: number
|
||||
readonly end: number
|
||||
readonly isMatch: boolean
|
||||
}
|
||||
|
||||
/** Return the included ranges, in addition to the ranges before, between,
|
||||
@ -37,3 +34,88 @@ export function* allRanges(
|
||||
yield { start: lastEndIndex, end, isMatch: false }
|
||||
}
|
||||
}
|
||||
|
||||
export class Range {
|
||||
constructor(
|
||||
readonly start: number,
|
||||
readonly end: number,
|
||||
) {}
|
||||
|
||||
/** Create the smallest possible {@link Range} that contains both {@link Range}s.
|
||||
* It is not necessary for the two {@link Range}s to overlap. */
|
||||
merge(other: Range): Range {
|
||||
return new Range(Math.min(this.start, other.start), Math.max(this.end, other.end))
|
||||
}
|
||||
|
||||
/** Create a new {@link Range} representing *exactly* the sub-ranges that are present in this
|
||||
* {@link Range} but not the other.
|
||||
*
|
||||
* Specifically:
|
||||
* - If the other {@link Range} overlaps this one on the left or right, return the single segment
|
||||
* of this {@link Range} that is not overlapped.
|
||||
* - If the other {@link Range} is fully within this range, return the two non-overlapped portions
|
||||
* (both left and right) of this {@link Range}.
|
||||
* - If the other {@link Range} fully contains this {@link Range}, return an empty array. */
|
||||
exclude(other: Range): Range[] {
|
||||
if (this.start < other.start) {
|
||||
const before = new Range(this.start, other.start)
|
||||
if (this.end > other.end) return [before, new Range(other.end, this.end)]
|
||||
else return [before]
|
||||
} else if (this.end > other.end) return [new Range(other.end, this.end)]
|
||||
else return []
|
||||
}
|
||||
|
||||
intersects(other: Range) {
|
||||
return this.start < other.end && this.end > other.start
|
||||
}
|
||||
|
||||
expand(by: number) {
|
||||
return new Range(this.start - by, this.end + by)
|
||||
}
|
||||
}
|
||||
|
||||
/** A sorted array of non-intersecting ranges. */
|
||||
export class MultiRange {
|
||||
// This MUST be readonly, otherwise a consumer may mutate it so that it is no longer sorted or
|
||||
// non-intersecting.
|
||||
readonly ranges: readonly Range[] = []
|
||||
constructor() {}
|
||||
|
||||
private get _ranges(): Range[] {
|
||||
return this.ranges as Range[]
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._ranges.splice(0, this._ranges.length)
|
||||
}
|
||||
|
||||
insert(range: Range, effectiveRange = range) {
|
||||
const start = partitionPoint(this._ranges, (r) => r.end < effectiveRange.start)
|
||||
const end = partitionPoint(this._ranges, (r) => r.start <= effectiveRange.end, start)
|
||||
let finalRange = range
|
||||
if (end !== start) {
|
||||
const startRange = this._ranges[start]
|
||||
if (startRange) finalRange = finalRange.merge(startRange)
|
||||
}
|
||||
if (end - 1 > start) {
|
||||
const endRange = this._ranges[end - 1]
|
||||
if (endRange) finalRange = finalRange.merge(endRange)
|
||||
}
|
||||
return this._ranges.splice(start, end - start, finalRange)[0]!
|
||||
}
|
||||
|
||||
remove(range: Range, effectiveRange = range) {
|
||||
const start = partitionPoint(this._ranges, (r) => r.end < effectiveRange.start)
|
||||
const end = partitionPoint(this._ranges, (r) => r.start <= effectiveRange.end, start)
|
||||
const finalRanges: Range[] = []
|
||||
if (end !== start) {
|
||||
const startRange = this._ranges[start]
|
||||
if (startRange) finalRanges.push(...startRange.exclude(range))
|
||||
}
|
||||
if (end - 1 > start) {
|
||||
const endRange = this._ranges[end - 1]
|
||||
if (endRange) finalRanges.push(...endRange.exclude(range))
|
||||
}
|
||||
return this._ranges.splice(start, end - start, ...finalRanges)
|
||||
}
|
||||
}
|
||||
|
@ -4,19 +4,40 @@ import { Vec2 } from '@/util/vec2'
|
||||
* Axis-aligned rectangle. Defined in terms of a top-left point and a size.
|
||||
*/
|
||||
export class Rect {
|
||||
pos: Vec2
|
||||
size: Vec2
|
||||
constructor(pos: Vec2, size: Vec2) {
|
||||
this.pos = pos
|
||||
this.size = size
|
||||
constructor(
|
||||
readonly pos: Vec2,
|
||||
readonly size: Vec2,
|
||||
) {}
|
||||
|
||||
static Zero = new Rect(Vec2.Zero, Vec2.Zero)
|
||||
|
||||
get left(): number {
|
||||
return this.pos.x
|
||||
}
|
||||
|
||||
get top(): number {
|
||||
return this.pos.y
|
||||
}
|
||||
|
||||
get bottom(): number {
|
||||
return this.pos.y + this.size.y
|
||||
}
|
||||
|
||||
get right(): number {
|
||||
return this.pos.x + this.size.x
|
||||
}
|
||||
|
||||
equals(other: Rect): boolean {
|
||||
return this.pos.equals(other.pos) && this.size.equals(other.size)
|
||||
}
|
||||
|
||||
static Zero(): Rect {
|
||||
return new Rect(Vec2.Zero(), Vec2.Zero())
|
||||
within(other: Rect): boolean {
|
||||
return (
|
||||
this.left >= other.left &&
|
||||
this.right <= other.right &&
|
||||
this.top >= other.top &&
|
||||
this.bottom <= other.bottom
|
||||
)
|
||||
}
|
||||
|
||||
center(): Vec2 {
|
||||
@ -26,4 +47,16 @@ export class Rect {
|
||||
rangeX(): [number, number] {
|
||||
return [this.pos.x, this.pos.x + this.size.x]
|
||||
}
|
||||
|
||||
intersectsX(other: Rect): boolean {
|
||||
return this.left < other.right && this.right > other.left
|
||||
}
|
||||
|
||||
intersectsY(other: Rect): boolean {
|
||||
return this.top < other.bottom && this.bottom > other.top
|
||||
}
|
||||
|
||||
intersects(other: Rect): boolean {
|
||||
return this.intersectsX(other) && this.intersectsY(other)
|
||||
}
|
||||
}
|
||||
|
@ -3,15 +3,13 @@
|
||||
* depends on the context where it is used.
|
||||
*/
|
||||
export class Vec2 {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
constructor(x: number, y: number) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
}
|
||||
static Zero(): Vec2 {
|
||||
return new Vec2(0, 0)
|
||||
}
|
||||
constructor(
|
||||
readonly x: number,
|
||||
readonly y: number,
|
||||
) {}
|
||||
|
||||
static Zero = new Vec2(0, 0)
|
||||
|
||||
static FromArr(arr: [number, number]): Vec2 {
|
||||
return new Vec2(arr[0], arr[1])
|
||||
}
|
||||
@ -19,41 +17,53 @@ export class Vec2 {
|
||||
equals(other: Vec2): boolean {
|
||||
return this.x === other.x && this.y === other.y
|
||||
}
|
||||
|
||||
isZero(): boolean {
|
||||
return this.x === 0 && this.y === 0
|
||||
}
|
||||
|
||||
scale(scalar: number): Vec2 {
|
||||
return new Vec2(this.x * scalar, this.y * scalar)
|
||||
}
|
||||
distanceSquare(other: Vec2): number {
|
||||
|
||||
distanceSquared(other: Vec2): number {
|
||||
const dx = this.x - other.x
|
||||
const dy = this.y - other.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
add(other: Vec2): Vec2 {
|
||||
return new Vec2(this.x + other.x, this.y + other.y)
|
||||
}
|
||||
|
||||
copy(): Vec2 {
|
||||
return new Vec2(this.x, this.y)
|
||||
}
|
||||
|
||||
addScaled(other: Vec2, scale: number): Vec2 {
|
||||
return new Vec2(other.x * scale + this.x, other.y * scale + this.y)
|
||||
}
|
||||
|
||||
sub(other: Vec2): Vec2 {
|
||||
return new Vec2(this.x - other.x, this.y - other.y)
|
||||
}
|
||||
|
||||
lengthSquared(): number {
|
||||
return this.x * this.x + this.y * this.y
|
||||
}
|
||||
|
||||
length(): number {
|
||||
return Math.sqrt(this.lengthSquared())
|
||||
}
|
||||
|
||||
min(other: Vec2): Vec2 {
|
||||
return new Vec2(Math.min(this.x, other.x), Math.min(this.y, other.y))
|
||||
}
|
||||
|
||||
max(other: Vec2): Vec2 {
|
||||
return new Vec2(Math.max(this.x, other.x), Math.max(this.y, other.y))
|
||||
}
|
||||
|
||||
lerp(to: Vec2, t: number): Vec2 {
|
||||
return new Vec2(this.x + (to.x - this.x) * t, this.y + (to.y - this.y) * t)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user