A graph that grew past one screen needs somewhere to say what a part of it is for (#38)
game-maker / suite (push) Successful in 6m13s
game-maker / suite (push) Successful in 6m13s
The graph could hold any number of nodes and said nothing about which of them belonged together. Groups and comments are two separate things, as decided in the issue: a group is a titled box that carries the nodes it encloses, a comment is free text that carries nothing. Both live as optional arrays on Graph, not as registry nodes. A comment that were a real node would run through the interpreter, show up in the drop palettes and be compiled into a shader — the wrong place for a piece of text. As arrays on Graph they exist in all four graph kinds at once, and a document without them means exactly "no groups", so no format bump. Membership is computed when the box is grabbed, never stored: the center of a node inside the box travels with it, and a node dragged out is simply out. A box inside a box travels with the outer one, its own nodes included. A group is grabbed by its title bar only — its body stays transparent to the pointer, or a rubber band could never be drawn across the nodes it holds. Deleting a box deletes the box; the nodes stay where they are, because a box that silently took ten nodes with it would be the most expensive surprise in the feature. The color is a free picker, and the title color follows from it: relative luminance per WCAG, threshold at 0.179 where the two contrast ratios meet — not 0.5, which would pick white on mid grey where black reads better. Resizing snaps the far edge to the grid, not the width, so the corner that is not being dragged stays put. The minimum size rises in whole steps for that reason: 80 is no multiple of 24, so a box squeezed to the stop would have sat off-grid in exactly the mode meant to keep it on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1135205d69
commit
b9f1a9d319
21 files changed
+1473
-22
No files matched your search
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Groups and comments: boxes drawn behind a graph. Both kinds take the same commands, because they
|
||||
* hold the same thing – which list they live in is the whole difference, and it is a parameter.
|
||||
*/
|
||||
import {
|
||||
type AnnotationKind,
|
||||
annotationList,
|
||||
annotationsOf,
|
||||
type GraphAnnotation,
|
||||
MIN_ANNOTATION_SIZE,
|
||||
} from '../graph/Graph';
|
||||
import { newId } from '../ids';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Removed, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireGraph } from './require';
|
||||
|
||||
/** What each kind is called where a user reads it – a "note" is a word only the model uses. */
|
||||
export function annotationNoun(kind: AnnotationKind): string {
|
||||
return kind === 'group' ? 'group' : 'comment';
|
||||
}
|
||||
|
||||
export interface AnnotationBox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Rounded to whole units and never smaller than the minimum, wherever a box comes from. */
|
||||
export function normalizeBox(box: AnnotationBox): AnnotationBox {
|
||||
return {
|
||||
x: Math.round(box.x),
|
||||
y: Math.round(box.y),
|
||||
w: Math.max(Math.round(box.w), MIN_ANNOTATION_SIZE.w),
|
||||
h: Math.max(Math.round(box.h), MIN_ANNOTATION_SIZE.h),
|
||||
};
|
||||
}
|
||||
|
||||
function requireAnnotation(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
kind: AnnotationKind,
|
||||
id: string,
|
||||
): GraphAnnotation {
|
||||
const found = annotationsOf(requireGraph(project, assetId), kind).find((a) => a.id === id);
|
||||
if (!found) throw new CommandError(`${annotationNoun(kind)} ${id} not found`);
|
||||
return found;
|
||||
}
|
||||
|
||||
export class AddAnnotationCommand implements Command {
|
||||
readonly label: string;
|
||||
readonly annotation: GraphAnnotation;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly kind: AnnotationKind,
|
||||
box: AnnotationBox,
|
||||
text: string,
|
||||
) {
|
||||
this.annotation = { id: newId(), ...normalizeBox(box), text };
|
||||
this.label = `Add ${annotationNoun(kind)}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
annotationList(requireGraph(project, this.assetId), this.kind).push(this.annotation);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const list = annotationList(requireGraph(project, this.assetId), this.kind);
|
||||
removeWhere(list, (a) => a.id === this.annotation.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the box away and nothing else: the nodes it framed stay where they are. A box that
|
||||
* silently deleted ten nodes with it would be the most expensive surprise in the editor – whoever
|
||||
* wants the nodes gone selects them and presses Delete, which the graph could already do.
|
||||
*/
|
||||
export class RemoveAnnotationCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed: Removed<GraphAnnotation>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly kind: AnnotationKind,
|
||||
private readonly id: string,
|
||||
) {
|
||||
this.label = `Delete ${annotationNoun(kind)}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const list = annotationList(requireGraph(project, this.assetId), this.kind);
|
||||
this.removed = removeWhere(list, (a) => a.id === this.id);
|
||||
if (this.removed.length === 0)
|
||||
throw new CommandError(`${annotationNoun(this.kind)} ${this.id} not found`);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
restoreAt(annotationList(requireGraph(project, this.assetId), this.kind), this.removed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move and resize in one command, since both are the same edit to the same four numbers. As with
|
||||
* `MoveNodeCommand` the previous box is read at execution time, so a caller that previewed the
|
||||
* drag must put the box back before executing.
|
||||
*/
|
||||
export class SetAnnotationBoxCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: AnnotationBox = { x: 0, y: 0, w: 0, h: 0 };
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly kind: AnnotationKind,
|
||||
private readonly id: string,
|
||||
private readonly box: AnnotationBox,
|
||||
label?: string,
|
||||
) {
|
||||
this.label = label ?? `Move ${annotationNoun(kind)}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const a = requireAnnotation(project, this.assetId, this.kind, this.id);
|
||||
this.previous = { x: a.x, y: a.y, w: a.w, h: a.h };
|
||||
Object.assign(a, normalizeBox(this.box));
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
Object.assign(requireAnnotation(project, this.assetId, this.kind, this.id), this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
export class SetAnnotationTextCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous = '';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly kind: AnnotationKind,
|
||||
private readonly id: string,
|
||||
private readonly text: string,
|
||||
) {
|
||||
this.label = kind === 'group' ? 'Set title' : 'Set comment';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const a = requireAnnotation(project, this.assetId, this.kind, this.id);
|
||||
this.previous = a.text;
|
||||
a.text = this.text;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireAnnotation(project, this.assetId, this.kind, this.id).text = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` clears the colour, which is how a box goes back to the theme's default. */
|
||||
export class SetAnnotationColorCommand implements Command {
|
||||
readonly label = 'Set color';
|
||||
private previous: number | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly kind: AnnotationKind,
|
||||
private readonly id: string,
|
||||
private readonly color: number | null,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const a = requireAnnotation(project, this.assetId, this.kind, this.id);
|
||||
this.previous = a.color ?? null;
|
||||
apply(a, this.color);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
apply(requireAnnotation(project, this.assetId, this.kind, this.id), this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
function apply(annotation: GraphAnnotation, color: number | null): void {
|
||||
if (color === null) delete annotation.color;
|
||||
else annotation.color = color;
|
||||
}
|
||||
@@ -57,15 +57,71 @@ export interface GraphEdge {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A box drawn behind the nodes, carrying a line of text and a colour. Two kinds share this shape
|
||||
* because they differ in what they *mean*, not in what they hold: a group frames the nodes whose
|
||||
* centre lies in it and takes them along when moved, a comment frames nothing and is read.
|
||||
* Purely visual either way – no interpreter ever looks at one.
|
||||
*/
|
||||
export interface GraphAnnotation {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
/** A group's title line; a comment's body, which wraps. */
|
||||
text: string;
|
||||
/** 0xRRGGBB. Absent means the theme's default, so a project never stores a colour nobody chose. */
|
||||
color?: number;
|
||||
}
|
||||
|
||||
/** Which of the two lists an annotation lives in – the only thing that tells them apart. */
|
||||
export type AnnotationKind = 'group' | 'note';
|
||||
|
||||
/** Smallest box either kind may be dragged down to; below this the text has nowhere to go. */
|
||||
export const MIN_ANNOTATION_SIZE = { w: 80, h: 48 };
|
||||
|
||||
export interface Graph {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
/** Titled boxes that carry what they enclose. Absent means none – an old document says exactly that. */
|
||||
groups?: GraphAnnotation[];
|
||||
notes?: GraphAnnotation[];
|
||||
}
|
||||
|
||||
export function createEmptyGraph(): Graph {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
/** The list of one kind, created on demand: a graph without annotations stores neither array. */
|
||||
export function annotationList(graph: Graph, kind: AnnotationKind): GraphAnnotation[] {
|
||||
const list = kind === 'group' ? graph.groups : graph.notes;
|
||||
if (list) return list;
|
||||
const fresh: GraphAnnotation[] = [];
|
||||
if (kind === 'group') graph.groups = fresh;
|
||||
else graph.notes = fresh;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Reads without creating, so drawing and lookups leave an untouched graph untouched. */
|
||||
export function annotationsOf(graph: Graph, kind: AnnotationKind): readonly GraphAnnotation[] {
|
||||
return (kind === 'group' ? graph.groups : graph.notes) ?? [];
|
||||
}
|
||||
|
||||
/** An annotation by id, whichever list holds it. Ids are unique across both. */
|
||||
export function findAnnotation(
|
||||
graph: Graph,
|
||||
id: string,
|
||||
): { kind: AnnotationKind; annotation: GraphAnnotation } | null {
|
||||
for (const kind of ANNOTATION_KINDS) {
|
||||
const annotation = annotationsOf(graph, kind).find((a) => a.id === id);
|
||||
if (annotation) return { kind, annotation };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ANNOTATION_KINDS: readonly AnnotationKind[] = ['group', 'note'];
|
||||
|
||||
export function findNode(graph: Graph, nodeId: string): GraphNode | undefined {
|
||||
return graph.nodes.find((n) => n.id === nodeId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Importing the graph package index is what registers the built-in nodes.
|
||||
import {
|
||||
ANNOTATION_KINDS,
|
||||
annotationList,
|
||||
createEmptyGraph,
|
||||
type DeclaredPort,
|
||||
type GraphAnnotation,
|
||||
MIN_ANNOTATION_SIZE,
|
||||
type DeclaredPortKind,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
@@ -369,9 +373,40 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph {
|
||||
if (label) edge.label = label;
|
||||
graph.edges.push(edge);
|
||||
}
|
||||
for (const kind of ANNOTATION_KINDS) {
|
||||
const list = normalizeAnnotations(g[kind === 'group' ? 'groups' : 'notes'], nodeIds);
|
||||
if (list.length > 0) annotationList(graph, kind).push(...list);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotations share the id space with nodes and with each other: a duplicate would make one
|
||||
* selection mean two boxes. A size below the minimum is raised rather than dropped – a box nobody
|
||||
* can see is worse than one that is a little larger than it was written.
|
||||
*/
|
||||
function normalizeAnnotations(raw: unknown, takenIds: Set<string>): GraphAnnotation[] {
|
||||
const list: GraphAnnotation[] = [];
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const a = asRecord(item);
|
||||
const id = typeof a.id === 'string' ? a.id : '';
|
||||
if (!id || takenIds.has(id)) continue;
|
||||
takenIds.add(id);
|
||||
const annotation: GraphAnnotation = {
|
||||
id,
|
||||
x: toNumber(a.x),
|
||||
y: toNumber(a.y),
|
||||
w: Math.max(toNumber(a.w), MIN_ANNOTATION_SIZE.w),
|
||||
h: Math.max(toNumber(a.h), MIN_ANNOTATION_SIZE.h),
|
||||
text: typeof a.text === 'string' ? a.text : '',
|
||||
};
|
||||
if (typeof a.color === 'number' && Number.isFinite(a.color))
|
||||
annotation.color = Math.max(0, Math.min(0xffffff, Math.floor(a.color)));
|
||||
list.push(annotation);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file name that is not the server's content-addressed form is dropped: a request built from it
|
||||
* would 404, and the texture then loads as "no picture".
|
||||
|
||||
Reference in new issue
Block a user