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>
121 lines
4.5 KiB
TypeScript
121 lines
4.5 KiB
TypeScript
import { Graphics, Rectangle, Text, TextStyle } from 'pixi.js';
|
||
import type { AnnotationKind, GraphAnnotation } from '../../core/graph';
|
||
import { readableTextColor, Theme, truncate, Widget } from '../../ui';
|
||
|
||
/** Height of a group's title bar – the same as a node's, so the two line up in the eye. */
|
||
export const ANNOTATION_TITLE_HEIGHT = 24;
|
||
/** Side of the square at the bottom right corner that resizes the box. */
|
||
export const RESIZE_HANDLE = 14;
|
||
const PAD = 8;
|
||
|
||
/**
|
||
* One group or comment, drawn behind the nodes in graph coordinates.
|
||
*
|
||
* A group is hit on its title bar and its resize handle only: its body has to stay transparent to
|
||
* the pointer, or a rubber band could never be drawn across the nodes it holds. A comment frames
|
||
* nothing, so it is grabbed anywhere.
|
||
*/
|
||
export class AnnotationView extends Widget {
|
||
private readonly box = new Graphics();
|
||
private readonly caption: Text;
|
||
selected = false;
|
||
|
||
constructor(
|
||
public annotation: GraphAnnotation,
|
||
readonly kind: AnnotationKind,
|
||
) {
|
||
super();
|
||
this.addChild(this.box);
|
||
this.caption = new Text({ text: '', style: this.captionStyle(0xffffff) });
|
||
this.caption.position.set(PAD, kind === 'group' ? 0 : PAD);
|
||
this.addChild(this.caption);
|
||
this.cursor = Theme.hoverCursor;
|
||
this.sync(annotation);
|
||
}
|
||
|
||
sync(annotation: GraphAnnotation): void {
|
||
this.annotation = annotation;
|
||
this.position.set(annotation.x, annotation.y);
|
||
this.resize(annotation.w, annotation.h);
|
||
// `resize` puts the full rectangle back every time, changed size or not, so this comes after it.
|
||
this.applyHitArea();
|
||
this.redraw();
|
||
}
|
||
|
||
get color(): number {
|
||
return this.annotation.color ?? Theme.colors.annotation;
|
||
}
|
||
|
||
/** Black or white, picked from the fill it sits on – the price of letting the picker run free. */
|
||
get textColor(): number {
|
||
return readableTextColor(this.color);
|
||
}
|
||
|
||
private captionStyle(color: number): TextStyle {
|
||
return new TextStyle({
|
||
fontFamily: Theme.font,
|
||
fontSize: Theme.fontSize,
|
||
fill: color,
|
||
fontWeight: this.kind === 'group' ? '600' : '400',
|
||
wordWrap: this.kind === 'note',
|
||
wordWrapWidth: Math.max(this.w - PAD * 2, 1),
|
||
lineHeight: Theme.fontSize + 4,
|
||
});
|
||
}
|
||
|
||
/** Where the box answers the pointer: the title bar of a group, the whole card of a comment. */
|
||
private applyHitArea(): void {
|
||
if (this.kind === 'note') {
|
||
this.hitArea = new Rectangle(0, 0, this.w, this.h);
|
||
return;
|
||
}
|
||
this.hitArea = {
|
||
contains: (x: number, y: number) =>
|
||
(x >= 0 && x <= this.w && y >= 0 && y <= ANNOTATION_TITLE_HEIGHT) || this.handleAt(x, y),
|
||
};
|
||
}
|
||
|
||
/** Whether this local point is on the corner that resizes – outside the hit area for a group,
|
||
* so the canvas asks for it before it asks who is under the pointer. */
|
||
handleAt(x: number, y: number): boolean {
|
||
return x >= this.w - RESIZE_HANDLE && x <= this.w && y >= this.h - RESIZE_HANDLE && y <= this.h;
|
||
}
|
||
|
||
override redraw(): void {
|
||
const c = Theme.colors;
|
||
const color = this.color;
|
||
const text = this.textColor;
|
||
this.box.clear();
|
||
if (this.kind === 'group') {
|
||
// Translucent body, solid title: the nodes inside must stay readable, the title must not.
|
||
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill({ color, alpha: 0.12 });
|
||
this.box.roundRect(0, 0, this.w, ANNOTATION_TITLE_HEIGHT, Theme.radius).fill(color);
|
||
this.box.rect(0, ANNOTATION_TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(color);
|
||
} else {
|
||
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(color);
|
||
}
|
||
this.box
|
||
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
|
||
.stroke({ width: this.selected ? 2 : 1, color: this.selected ? c.nodeSelected : color, alpha: 0.9 });
|
||
// A corner that can be grabbed says so, in the text colour of the box it belongs to.
|
||
this.box
|
||
.moveTo(this.w - RESIZE_HANDLE, this.h - 2)
|
||
.lineTo(this.w - 2, this.h - RESIZE_HANDLE)
|
||
.moveTo(this.w - RESIZE_HANDLE / 2, this.h - 2)
|
||
.lineTo(this.w - 2, this.h - RESIZE_HANDLE / 2)
|
||
.stroke({ width: 1, color: text, alpha: 0.5 });
|
||
|
||
this.caption.style = this.captionStyle(text);
|
||
this.caption.text =
|
||
this.kind === 'group'
|
||
? truncate(this.annotation.text, this.caption.style, Math.max(this.w - PAD * 2, 1))
|
||
: this.annotation.text;
|
||
if (this.kind === 'group') this.caption.y = (ANNOTATION_TITLE_HEIGHT - this.caption.height) / 2;
|
||
}
|
||
|
||
/** What the last redraw put on screen, for the checks. */
|
||
drawnText(): string {
|
||
return this.caption.text;
|
||
}
|
||
}
|