feat(entity): Chunk #4 data layer + entity UI (characters & world-building)
Data layer: - Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests - Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks - Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests - StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity; implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search - WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind - @mention autocomplete extension + picker in the editor package Entity UI (apps/web/src/features/entities/): - Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef - Tabbed directory with fuzzy search + entity cards (EntitiesView) - Create/edit/delete drawer (EntityEditor) reusing shared fields - Relationship editor + force-directed SVG relationship map - Entity engine stylesheet; wired into nav Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract. CI-green: typecheck, lint, 65 tests, and web build all pass.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* EntitiesView — the entity engine directory (Chunk #4).
|
||||
*
|
||||
* A tabbed, fuzzy-searchable directory of people, places, and notes. Each card
|
||||
* opens the create/edit drawer. A "New" control creates an entity of a chosen
|
||||
* kind. Characters show a relationship map in the drawer.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Entity } from "@pubscript/core";
|
||||
import { fuzzySort } from "@pubscript/core";
|
||||
import { Button, Input, Select } from "@pubscript/ui";
|
||||
import { useProjectStore } from "../../store/project-store.js";
|
||||
import { EntityEditor } from "./EntityEditor.js";
|
||||
import { entityLabel } from "./RelationshipEditor.js";
|
||||
|
||||
type TabId = "all" | "people" | "places" | "notes";
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
/** Filter predicate over entities, or null for "all". */
|
||||
match: (e: Entity) => boolean;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: "all", label: "All", match: () => true },
|
||||
{ id: "people", label: "People", match: (e) => "relationships" in e },
|
||||
{ id: "places", label: "Places", match: (e) => "fields" in e },
|
||||
{ id: "notes", label: "Notes", match: (e) => "body" in e },
|
||||
];
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: "character", label: "Character" },
|
||||
{ value: "world", label: "Place" },
|
||||
{ value: "note", label: "Note" },
|
||||
];
|
||||
|
||||
/** Human-readable text used for fuzzy matching. */
|
||||
function searchText(e: Entity): string {
|
||||
if ("fields" in e) {
|
||||
return [e.name, e.kind, ...Object.values(e.fields).filter((v) => typeof v === "string")].join(" ");
|
||||
}
|
||||
if ("body" in e) {
|
||||
return [e.body, ...e.tags].join(" ");
|
||||
}
|
||||
const c = e;
|
||||
const rel = (c.relationships ?? []).map((r) => r.kind).join(" ");
|
||||
return [c.name, c.role, c.bio, c.appearance, c.voice, c.arc, c.status, rel, ...c.tags].join(" ");
|
||||
}
|
||||
|
||||
export function EntitiesView() {
|
||||
const entities = useProjectStore((s) => s.entities);
|
||||
const createEntity = useProjectStore((s) => s.createEntity);
|
||||
const updateEntity = useProjectStore((s) => s.updateEntity);
|
||||
const deleteEntity = useProjectStore((s) => s.deleteEntity);
|
||||
|
||||
const [tab, setTab] = React.useState<TabId>("all");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [newKind, setNewKind] = React.useState("character");
|
||||
const [draft, setDraft] = React.useState<Entity | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
|
||||
return query ? fuzzySort(filtered, query, searchText) : filtered;
|
||||
}, [entities, tab, query]);
|
||||
|
||||
const openNew = async () => {
|
||||
const e = await createEntity(newKind);
|
||||
setDraft(e);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEntity = (e: Entity) => {
|
||||
setDraft(e);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const save = (e: Entity) => {
|
||||
void updateEntity(e);
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await deleteEntity(id);
|
||||
setOpen(false);
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-entities-view">
|
||||
<div className="ps-entity-toolbar">
|
||||
<div className="ps-entity-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ps-entity-new-group">
|
||||
<Select
|
||||
className="ps-entity-new-kind"
|
||||
value={newKind}
|
||||
options={KIND_OPTIONS}
|
||||
onChange={(ev) => setNewKind(ev.target.value)}
|
||||
/>
|
||||
<Button onClick={openNew}>+ New</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-entity-search">
|
||||
<Input
|
||||
className="ps-entity-search-input"
|
||||
placeholder="Search entities…"
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="ps-muted">No entities match.</p>
|
||||
) : (
|
||||
<div className="ps-entity-grid">
|
||||
{visible.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
className="ps-entity-card"
|
||||
onClick={() => openEntity(e)}
|
||||
>
|
||||
<div className="ps-entity-card-top">
|
||||
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
|
||||
</div>
|
||||
<div className="ps-entity-card-name">{entityLabel(e)}</div>
|
||||
<div className="ps-entity-card-sub">{subFor(e)}</div>
|
||||
{tagsFor(e).length > 0 && (
|
||||
<div className="ps-entity-card-tags">
|
||||
{tagsFor(e).slice(0, 4).map((t) => (
|
||||
<span key={t} className="ps-entity-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityEditor
|
||||
entity={draft}
|
||||
entities={entities}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onSave={save}
|
||||
onDelete={remove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Short badge label for a card. */
|
||||
function badgeFor(e: Entity): string {
|
||||
if ("fields" in e) return e.kind;
|
||||
if ("body" in e) return "note";
|
||||
return "person";
|
||||
}
|
||||
|
||||
/** Secondary line shown under the name. */
|
||||
function subFor(e: Entity): string {
|
||||
if ("fields" in e) return e.kind;
|
||||
if ("body" in e) return e.body.slice(0, 80) || "Note";
|
||||
const c = e;
|
||||
return c.role || c.status || "";
|
||||
}
|
||||
|
||||
/** Tags for a card. */
|
||||
function tagsFor(e: Entity): string[] {
|
||||
if ("fields" in e) return [];
|
||||
if ("body" in e) return e.tags;
|
||||
return e.tags;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* EntityEditor — the right-rail drawer for creating and editing an entity.
|
||||
*
|
||||
* Composes the generic `EntityFields` renderer plus, for characters, the
|
||||
* `RelationshipEditor`. Emits the full entity on Save and deletes on demand.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Character, Entity } from "@pubscript/core";
|
||||
import { Button } from "@pubscript/ui";
|
||||
import { EntityFields } from "./EntityFields.js";
|
||||
import { RelationshipEditor } from "./RelationshipEditor.js";
|
||||
import { RelationshipMap } from "./RelationshipMap.js";
|
||||
|
||||
interface EntityEditorProps {
|
||||
entity: Entity | null;
|
||||
entities: Entity[];
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (entity: Entity) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function isCharacter(e: Entity): e is Character {
|
||||
return "relationships" in e;
|
||||
}
|
||||
|
||||
export function EntityEditor({
|
||||
entity,
|
||||
entities,
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: EntityEditorProps) {
|
||||
const [draft, setDraft] = React.useState<Entity | null>(entity);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDraft(entity);
|
||||
}, [entity]);
|
||||
|
||||
if (!open || !draft) return null;
|
||||
|
||||
const isExisting = entities.some((e) => e.id === draft.id);
|
||||
const isCharacterDraft = isCharacter(draft);
|
||||
const canDelete = isExisting && isCharacterDraft;
|
||||
|
||||
const title = isCharacterDraft
|
||||
? (draft.name || "Edit character")
|
||||
: "fields" in draft
|
||||
? `Edit ${draft.kind}`
|
||||
: "Edit note";
|
||||
|
||||
const patch = (p: Partial<Entity>) => {
|
||||
setDraft({ ...draft, ...p });
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
onSave(draft);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="ps-entity-editor">
|
||||
<header className="ps-entity-editor-header">
|
||||
<h2>{isExisting ? title : "New entity"}</h2>
|
||||
<button type="button" className="ps-icon-btn" aria-label="Close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="ps-entity-editor-body">
|
||||
{isCharacterDraft ? (
|
||||
<>
|
||||
<EntityFields entity={draft} onChange={patch} />
|
||||
<div className="ps-relationship-section">
|
||||
<h3>Relationships</h3>
|
||||
<RelationshipEditor
|
||||
character={draft}
|
||||
entities={entities}
|
||||
onChange={(relationships) => patch({ relationships })}
|
||||
/>
|
||||
<div className="ps-relationship-map-wrap">
|
||||
<RelationshipMap entities={entities} focusId={draft.id} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EntityFields entity={draft} onChange={patch} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="ps-entity-editor-actions">
|
||||
<div className="ps-spacer" />
|
||||
{canDelete ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => onDelete(draft.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save}>Save</Button>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* EntityFields — generic field renderer for the entity editor.
|
||||
*
|
||||
* Renders the fields defined in the core `ENTITY_FIELDS` registry for the
|
||||
* entity's kind. Field values live at the top level for characters/notes and
|
||||
* inside the `fields` record for world items; `getFieldValue`/`setFieldValue`
|
||||
* abstract that away so the renderer is kind-agnostic.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Entity, FieldDef } from "@pubscript/core";
|
||||
import { ENTITY_FIELDS } from "@pubscript/core";
|
||||
import { Input } from "@pubscript/ui";
|
||||
import { Select } from "@pubscript/ui";
|
||||
|
||||
/** Read a field value from an entity, regardless of where it is stored. */
|
||||
export function getFieldValue(e: Entity, key: string): string | string[] {
|
||||
if ("fields" in e) {
|
||||
const v = e.fields[key];
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
const v = (e as unknown as Record<string, unknown>)[key];
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
/** Return a copy of `e` with `key` set to `value`. */
|
||||
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
|
||||
if ("fields" in e) {
|
||||
return { ...e, fields: { ...e.fields, [key]: value } };
|
||||
}
|
||||
return { ...e, [key]: value };
|
||||
}
|
||||
|
||||
/** The field definitions for an entity's kind. */
|
||||
export function fieldsFor(e: Entity): FieldDef[] {
|
||||
const kind = "fields" in e ? e.kind : "body" in e ? "note" : "character";
|
||||
return ENTITY_FIELDS[kind]?.fields ?? [];
|
||||
}
|
||||
|
||||
interface EntityFieldsProps {
|
||||
entity: Entity;
|
||||
onChange: (patch: Partial<Entity>) => void;
|
||||
}
|
||||
|
||||
function renderField(
|
||||
field: FieldDef,
|
||||
value: string | string[],
|
||||
onChange: (v: string | string[]) => void,
|
||||
) {
|
||||
const label = (
|
||||
<label className="ps-field-label">
|
||||
{field.label}
|
||||
{field.hint ? <span className="ps-field-hint">{field.hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
|
||||
switch (field.type) {
|
||||
case "textarea":
|
||||
return (
|
||||
<div className="ps-field-span-2">
|
||||
{label}
|
||||
<textarea
|
||||
className="ps-textarea ps-input"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "list":
|
||||
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
|
||||
case "link":
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<div className="ps-field-span-2">
|
||||
{label}
|
||||
<Input
|
||||
className="ps-input"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A small inline tag editor (add on Enter, remove on ×). */
|
||||
function TagField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = React.useState("");
|
||||
|
||||
const add = () => {
|
||||
const t = draft.trim();
|
||||
if (t && !value.includes(t)) {
|
||||
onChange([...value, t]);
|
||||
}
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-tag-field">
|
||||
<div className="ps-tag-list">
|
||||
{value.map((t, i) => (
|
||||
<span key={`${t}-${i}`} className="ps-tag-chip">
|
||||
{t}
|
||||
<button
|
||||
type="button"
|
||||
className="ps-tag-remove"
|
||||
aria-label={`Remove ${t}`}
|
||||
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="ps-input"
|
||||
value={draft}
|
||||
placeholder="Add tag + Enter"
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityFields({ entity, onChange }: EntityFieldsProps) {
|
||||
const fields = fieldsFor(entity);
|
||||
|
||||
return (
|
||||
<div className="ps-entity-fields">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="ps-field">
|
||||
{renderField(
|
||||
field,
|
||||
getFieldValue(entity, field.key),
|
||||
(v) => onChange({ [field.key]: v } as Partial<Entity>),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* RelationshipEditor — editor for a character's relationships.
|
||||
*
|
||||
* Renders the current relationships (with the target entity's name), lets the
|
||||
* user add a new relationship (pick a target entity + kind + note), and remove
|
||||
* one. Emits the new relationships array via `onChange`.
|
||||
*/
|
||||
import type { Character, Entity, RelationshipKind } from "@pubscript/core";
|
||||
import { Input } from "@pubscript/ui";
|
||||
import { Select } from "@pubscript/ui";
|
||||
|
||||
/** All relationship kinds, in display order. */
|
||||
export const RELATIONSHIP_KINDS: RelationshipKind[] = [
|
||||
"ally",
|
||||
"rival",
|
||||
"family",
|
||||
"sibling",
|
||||
"friend",
|
||||
"spouse",
|
||||
"partner",
|
||||
"mentor",
|
||||
"romantic",
|
||||
"custom",
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<RelationshipKind, string> = {
|
||||
ally: "Ally",
|
||||
rival: "Rival",
|
||||
family: "Family",
|
||||
sibling: "Sibling",
|
||||
friend: "Friend",
|
||||
spouse: "Spouse",
|
||||
partner: "Partner",
|
||||
mentor: "Mentor",
|
||||
romantic: "Romantic",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
interface RelationshipEditorProps {
|
||||
character: Character;
|
||||
/** All entities available as relationship targets. */
|
||||
entities: Entity[];
|
||||
onChange: (relationships: Character["relationships"]) => void;
|
||||
}
|
||||
|
||||
export function RelationshipEditor({
|
||||
character,
|
||||
entities,
|
||||
onChange,
|
||||
}: RelationshipEditorProps) {
|
||||
const targets = entities.filter((e) => e.id !== character.id);
|
||||
|
||||
const targetName = (id: string) => {
|
||||
const e = entities.find((e) => e.id === id);
|
||||
return e ? entityLabel(e) : "(unknown)";
|
||||
};
|
||||
|
||||
const remove = (targetId: string) => {
|
||||
onChange(character.relationships.filter((r) => r.targetId !== targetId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-relationship-editor">
|
||||
<div className="ps-relationship-list">
|
||||
{character.relationships.length === 0 && (
|
||||
<p className="ps-relationship-empty">
|
||||
No relationships yet. Add one below to start mapping this character's
|
||||
connections.
|
||||
</p>
|
||||
)}
|
||||
{character.relationships.map((r, i) => (
|
||||
<div key={`${r.targetId}-${i}`} className="ps-relationship-item">
|
||||
<span className="ps-relationship-kind">{KIND_LABEL[r.kind] ?? r.kind}</span>
|
||||
<span className="ps-relationship-target">{targetName(r.targetId)}</span>
|
||||
{r.note ? <span className="ps-relationship-note">{r.note}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ps-relationship-remove"
|
||||
aria-label="Remove relationship"
|
||||
onClick={() => remove(r.targetId)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ps-relationship-add">
|
||||
<Select
|
||||
className="ps-relationship-target-select"
|
||||
value=""
|
||||
options={[
|
||||
{ value: "", label: "Target…" },
|
||||
...targets.map((e) => ({ value: e.id, label: entityLabel(e) })),
|
||||
]}
|
||||
onChange={(ev) => {
|
||||
const targetId = ev.target.value;
|
||||
onChange([...character.relationships, { targetId, kind: "ally", note: "" }]);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="ps-relationship-kind-select"
|
||||
value="ally"
|
||||
options={RELATIONSHIP_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] }))}
|
||||
onChange={(ev) => {
|
||||
const kind = ev.target.value as RelationshipKind;
|
||||
const next = character.relationships.slice();
|
||||
const last = next[next.length - 1];
|
||||
if (last) last.kind = kind;
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="ps-relationship-note"
|
||||
placeholder="Note (optional)"
|
||||
value={character.relationships[character.relationships.length - 1]?.note ?? ""}
|
||||
onChange={(ev) => {
|
||||
const note = ev.target.value;
|
||||
const next = character.relationships.slice();
|
||||
const last = next[next.length - 1];
|
||||
if (last) last.note = note;
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Human label for an entity used as a relationship target. */
|
||||
export function entityLabel(e: Entity): string {
|
||||
if ("fields" in e) return `${e.name} (${e.kind})`;
|
||||
if ("body" in e) return e.body.slice(0, 40) || "Note";
|
||||
return e.name;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* RelationshipMap — a lightweight force-directed SVG graph of a character's
|
||||
* relationships.
|
||||
*
|
||||
* Pure SVG, no external graphing dependency. A small simulation runs once when
|
||||
* the node set changes to compute stable positions; edges are drawn as lines
|
||||
* and nodes as labelled circles. Non-character entities are ignored.
|
||||
*/
|
||||
import type { Character, Entity } from "@pubscript/core";
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
const WIDTH = 520;
|
||||
const HEIGHT = 320;
|
||||
const CENTER = { x: WIDTH / 2, y: HEIGHT / 2 };
|
||||
|
||||
/** Extract the characters from a list of entities. */
|
||||
function characters(entities: Entity[]): Character[] {
|
||||
return entities.filter((e): e is Character => "relationships" in e && e.name.length > 0);
|
||||
}
|
||||
|
||||
/** Human label for a node in the graph. */
|
||||
function nodeLabel(e: Entity): string {
|
||||
if ("fields" in e) return e.name;
|
||||
if ("body" in e) return e.body.slice(0, 12) || "Note";
|
||||
return e.name;
|
||||
}
|
||||
|
||||
/** Run a simple force simulation for a fixed number of steps to lay out nodes. */
|
||||
function layout(nodes: Node[], iterations = 240): Node[] {
|
||||
const REPULSION = 2600;
|
||||
const CENTER_FORCE = 0.004;
|
||||
const DAMPING = 0.85;
|
||||
const PAD = 40;
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
for (const a of nodes) {
|
||||
a.vx = 0;
|
||||
a.vy = 0;
|
||||
}
|
||||
// Pairwise repulsion.
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i]!;
|
||||
const b = nodes[j]!;
|
||||
let dx = a.x - b.x;
|
||||
let dy = a.y - b.y;
|
||||
let dist2 = dx * dx + dy * dy;
|
||||
if (dist2 < 1) {
|
||||
dx = (Math.random() - 0.5) * 2;
|
||||
dy = (Math.random() - 0.5) * 2;
|
||||
dist2 = dx * dx + dy * dy || 1;
|
||||
}
|
||||
const force = REPULSION / dist2;
|
||||
const dist = Math.sqrt(dist2);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
}
|
||||
// Center gravity.
|
||||
for (const n of nodes) {
|
||||
n.vx += (CENTER.x - n.x) * CENTER_FORCE;
|
||||
n.vy += (CENTER.y - n.y) * CENTER_FORCE;
|
||||
}
|
||||
// Integrate + clamp to padding.
|
||||
for (const n of nodes) {
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
n.x = Math.max(PAD, Math.min(WIDTH - PAD, n.x));
|
||||
n.y = Math.max(PAD, Math.min(HEIGHT - PAD, n.y));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const CHARACTER_COLOR = "#8b5cf6";
|
||||
const RELATED_COLOR = "#38bdf8";
|
||||
|
||||
interface RelationshipMapProps {
|
||||
entities: Entity[];
|
||||
/** The character whose relationships are centered in the graph. */
|
||||
focusId?: string;
|
||||
}
|
||||
|
||||
export function RelationshipMap({ entities, focusId }: RelationshipMapProps) {
|
||||
const chars = characters(entities);
|
||||
|
||||
// Build the node/edge set. Start from the focus character (or the first
|
||||
// character) and include everyone it is directly related to.
|
||||
const focus = focusId ? chars.find((c) => c.id === focusId) : chars[0];
|
||||
const focusChars = focus ? [focus] : chars;
|
||||
|
||||
const nodeIds = new Set<string>();
|
||||
const edges: Array<[string, string]> = [];
|
||||
for (const c of focusChars) {
|
||||
nodeIds.add(c.id);
|
||||
for (const r of c.relationships) {
|
||||
nodeIds.add(r.targetId);
|
||||
edges.push([c.id, r.targetId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed positions on a circle, then simulate.
|
||||
const seeded: Node[] = [...nodeIds].map((id, i) => {
|
||||
const angle = (2 * Math.PI * i) / Math.max(1, nodeIds.size);
|
||||
const label = nodeLabel(entities.find((e) => e.id === id)!) || id.slice(0, 6);
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
color: id === focus?.id ? CHARACTER_COLOR : RELATED_COLOR,
|
||||
x: CENTER.x + Math.cos(angle) * 110,
|
||||
y: CENTER.y + Math.sin(angle) * 70,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const laidOut = layout(seeded);
|
||||
const pos = new Map(laidOut.map((n) => [n.id, n]));
|
||||
|
||||
if (laidOut.length === 0) {
|
||||
return (
|
||||
<div className="ps-relationship-map-empty">
|
||||
{focusChars.length === 0
|
||||
? "No characters yet."
|
||||
: "Select a character to map its relationships."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} className="ps-relationship-svg" role="img" aria-label="Relationship map">
|
||||
<g>
|
||||
{edges.map(([from, to], i) => {
|
||||
const a = pos.get(from);
|
||||
const b = pos.get(to);
|
||||
if (!a || !b) return null;
|
||||
return <line key={`e-${i}`} x1={a.x} y1={a.y} x2={b.x} y2={b.y} className="ps-rel-edge" />;
|
||||
})}
|
||||
</g>
|
||||
<g>
|
||||
{laidOut.map((n) => (
|
||||
<g key={n.id}>
|
||||
<circle cx={n.x} cy={n.y} r={14} fill={n.color} className="ps-rel-node" />
|
||||
<text x={n.x} y={n.y + 3} textAnchor="middle" className="ps-rel-node-label">
|
||||
{n.label.length > 8 ? n.label.slice(0, 7) + "…" : n.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Entities feature barrel — Chunk #4 entity engine UI.
|
||||
*
|
||||
* Exposes the app-facing `EntitiesView` (the `/entities` route) and the
|
||||
* composable pieces it's built from, so other routes (e.g. the editor mention
|
||||
* integration) can reuse the editor and picker without importing internals.
|
||||
*/
|
||||
export { EntitiesView } from "./EntitiesView.js";
|
||||
export { EntityEditor } from "./EntityEditor.js";
|
||||
export { EntityFields } from "./EntityFields.js";
|
||||
export { RelationshipEditor } from "./RelationshipEditor.js";
|
||||
export { RelationshipMap } from "./RelationshipMap.js";
|
||||
Reference in New Issue
Block a user