New node placement (#8112)

- Closes #8069

# Important Notes
None
This commit is contained in:
somebody1234 2023-10-30 18:08:29 +10:00 committed by GitHub
parent 523a32471e
commit 7019de70b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 899 additions and 50 deletions

View File

@ -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>
`
}

View File

@ -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

View File

@ -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
}

View 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)) }
}

View File

@ -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, {

View File

@ -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

View File

@ -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

View File

@ -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,
}
}

View 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)
})

View 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]))
})

View File

@ -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
}

View File

@ -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

View 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
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}