Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions canvas/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
sendCommand,
} from "./api";
import { directCapabilities } from "./exec";
import { CanvasDrawer } from "./CanvasDrawer";
import { ModelCanvas, PALETTE, type ModelCanvasHandle } from "./ModelCanvas";
import { SidePanel, type PanelTab } from "./SidePanel";
import { TopBar } from "./TopBar";
Expand All @@ -31,6 +32,11 @@ function Canvas() {
const [expandedEntities, setExpandedEntities] = useState<Set<string>>(() => new Set());
const [selectedId, setSelectedId] = useState<string | null>(null);
const [panelTab, setPanelTab] = useState<PanelTab | null>(null);
// Left subject-area / layout drawer, and the active subject-area filter it drives. An empty
// `activeAreas` means "show all"; a non-empty set shows ONLY those domains (canvas hides the
// rest and fits to the visible set).
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeAreas, setActiveAreas] = useState<Set<string>>(() => new Set());
const [dirty, setDirty] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
// The graph, the inspector and the modals now live in <ModelCanvas>; the shell
Expand All @@ -56,6 +62,32 @@ function Canvas() {
return m;
}, [doc]);

// entity id -> subject-area id, and per-area entity counts, for the drawer filter. Resolved
// the same dual-sourced way ModelCanvas builds `areaByEntity`: the inverted subject_areas[]
// .members (conceptual ULIDs) first, then the direct entity.conceptual.subject_area pointer.
// "" keys the unassigned bucket. Shared with the canvas so the drawer counts and the hide
// filter agree.
const entityArea = useMemo(() => {
const m = new Map<string, string>();
if (!doc) return m;
const byConceptual = new Map<string, string>();
doc.subject_areas.forEach((sa) => {
(sa.members ?? []).forEach((cid) => byConceptual.set(cid, sa.id));
});
doc.entities.forEach((e) => {
const viaMembers = e.conceptual?.id ? byConceptual.get(e.conceptual.id) : undefined;
const id = viaMembers ?? e.conceptual?.subject_area?.id ?? "";
m.set(e.id, id);
});
return m;
}, [doc]);

const countByArea = useMemo(() => {
const m = new Map<string, number>();
entityArea.forEach((areaId) => m.set(areaId, (m.get(areaId) ?? 0) + 1));
return m;
}, [entityArea]);

const readOnly = doc?.read_only ?? true;
// The architect canvas writes straight to disk; its capabilities follow the
// server's read-only flag exactly.
Expand Down Expand Up @@ -234,9 +266,8 @@ function Canvas() {
collapseDetail={collapseDetail}
onToggleCollapse={() => setCollapseDetail((v) => !v)}
onFitView={() => canvasRef.current?.fitView()}
onRelayout={(mode) => canvasRef.current?.relayout(mode)}
onRelayout={(mode, dir) => canvasRef.current?.relayout(mode, dir)}
onRefresh={refresh}
saColors={saColors}
readOnly={readOnly}
dirty={dirty}
panelTab={panelTab}
Expand All @@ -246,6 +277,9 @@ function Canvas() {
onImported={refresh}
onImportBatch={applyImportBatch}
openImport={openImport}
onToggleDrawer={() => setDrawerOpen((v) => !v)}
drawerOpen={drawerOpen}
filterCount={activeAreas.size}
/>
)}
<div className="canvas-wrap">
Expand All @@ -260,9 +294,21 @@ function Canvas() {
collapseDetail={collapseDetail}
expandedEntities={expandedEntities}
onToggleExpand={onToggleExpand}
activeAreas={activeAreas}
onError={setToast}
handleRef={canvasRef}
/>
{!minimal && (
<CanvasDrawer
doc={doc}
saColors={saColors}
countByArea={countByArea}
activeAreas={activeAreas}
onSetActive={setActiveAreas}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
/>
)}
{panelTab && (
<SidePanel
tab={panelTab}
Expand Down
111 changes: 111 additions & 0 deletions canvas/src/CanvasDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { IconClose, IconDomains } from "./icons";
import type { ModelDoc } from "./types";

/** Left slide-out drawer: turns the subject-area colour key into an interactive FILTER. Each
* row is a domain — colour chip, name, entity count, and a checkbox. Clicking a row focuses
* that single domain (canvas hides the rest and fits to it); the checkboxes multi-select a
* union. "Show all" clears the filter. This replaces the old static, wrapping legend that
* crammed the toolbar. The drawer stays mounted so it can slide in/out; `.open` drives the
* transform. */
export function CanvasDrawer({
doc,
saColors,
countByArea,
activeAreas,
onSetActive,
open,
onClose,
}: {
doc: ModelDoc;
/** area id -> colour (shared with node colouring + minimap) */
saColors: Map<string, string>;
/** area id -> number of entities in it */
countByArea: Map<string, number>;
/** the set of area ids currently shown; empty = show all */
activeAreas: Set<string>;
/** replace the active set (focus = single, toggle = add/remove, clear = empty) */
onSetActive: (next: Set<string>) => void;
open: boolean;
onClose: () => void;
}) {
const areas = doc.subject_areas;
const filtering = activeAreas.size > 0;

const focus = (id: string) => onSetActive(new Set([id]));
const toggle = (id: string) => {
const next = new Set(activeAreas);
if (next.has(id)) next.delete(id);
else next.add(id);
onSetActive(next);
};

const unassigned = countByArea.get("") ?? 0;

return (
<aside className={"canvas-drawer" + (open ? " open" : "")} aria-hidden={!open}>
<div className="drawer-header">
<h2>
<IconDomains size={16} /> Subject areas
</h2>
<button className="icon-btn" onClick={onClose} title="Close" aria-label="Close drawer">
<IconClose size={16} />
</button>
</div>

<div className="drawer-hint">
{filtering
? `Showing ${activeAreas.size} of ${areas.length} — click a domain to focus, tick to add.`
: "Click a domain to show only it. Tick several to compare."}
</div>

<button
className={"sa-all" + (filtering ? "" : " active")}
onClick={() => onSetActive(new Set())}
disabled={!filtering}
>
Show all
</button>

<div className="sa-list">
{areas.map((sa) => {
const on = activeAreas.has(sa.id);
const count = countByArea.get(sa.id) ?? sa.member_count ?? 0;
return (
<div
key={sa.id}
className={"sa-row" + (on ? " active" : "")}
onClick={() => focus(sa.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
focus(sa.id);
}
}}
title={`Focus ${sa.name}`}
>
<input
type="checkbox"
checked={on}
onClick={(e) => e.stopPropagation()}
onChange={() => toggle(sa.id)}
aria-label={`Include ${sa.name}`}
/>
<span className="sa-chip" style={{ background: saColors.get(sa.id) }} />
<span className="sa-name">{sa.name}</span>
<span className="sa-count">{count}</span>
</div>
);
})}
{unassigned > 0 && (
<div className="sa-row muted" title="Entities with no subject area">
<span className="sa-chip none" />
<span className="sa-name">No subject area</span>
<span className="sa-count">{unassigned}</span>
</div>
)}
</div>
</aside>
);
}
Loading
Loading