feat: initial node groups #44
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getGraphManager } from '../graph-state.svelte';
|
||||||
|
const graph = getGraphManager();
|
||||||
|
|
||||||
|
function getGroupName(groupId: number) {
|
||||||
|
const group = graph.getGroup(groupId);
|
||||||
|
return group?.name || `Group#${groupId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitToGroup(groupId?: number) {
|
||||||
|
while (graph.graphStack.length > 0 && graph.graphStack.at(-1)?.groupId !== groupId) {
|
||||||
|
graph.exitGroup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="shadow" class:is-inside-group={graph.isInsideGroup}></div>
|
||||||
|
|
||||||
|
{#if graph.isInsideGroup}
|
||||||
|
<div class="group-name flex gap-1 items-center">
|
||||||
|
<button
|
||||||
|
class="bg-layer-2 opacity-75 hover:opacity-100 cursor-pointer rounded-sm p-1 px-2"
|
||||||
|
onclick={() => exitToGroup()}
|
||||||
|
>
|
||||||
|
Root
|
||||||
|
</button>
|
||||||
|
{#each graph.graphStack as group (group.groupId)}
|
||||||
|
<span class="i-[tabler--arrow-right]"></span>
|
||||||
|
<button
|
||||||
|
class="bg-layer-2 opacity-75 hover:opacity-100 cursor-pointer rounded-sm p-1 px-2"
|
||||||
|
onclick={() => exitToGroup(group.groupId)}
|
||||||
|
>
|
||||||
|
{getGroupName(group.groupId)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shadow {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
left: -5px;
|
||||||
|
right: calc(var(--padding-right) - 5px);
|
||||||
|
bottom: -5px;
|
||||||
|
z-index: 1;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
box-shadow: 0 0 0px 0px var(--color-layer-2) inset;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow.is-inside-group {
|
||||||
|
box-shadow: 0 0 0px 8px var(--color-layer-2) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% - var(--padding-right) / 2);
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
top: 12px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { clone } from '$lib/helpers';
|
||||||
import throttle from '$lib/helpers/throttle';
|
import throttle from '$lib/helpers/throttle';
|
||||||
import { RemoteNodeRegistry } from '$lib/node-registry/index';
|
import { RemoteNodeRegistry } from '$lib/node-registry/index';
|
||||||
import type {
|
import type {
|
||||||
@@ -16,6 +17,12 @@ import { fastHashString } from '@nodarium/utils';
|
|||||||
import { createLogger } from '@nodarium/utils';
|
import { createLogger } from '@nodarium/utils';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import EventEmitter from './helpers/EventEmitter';
|
import EventEmitter from './helpers/EventEmitter';
|
||||||
|
import {
|
||||||
|
areEdgesEqual,
|
||||||
|
areSocketsCompatible,
|
||||||
|
serializeEdge,
|
||||||
|
serializeNode
|
||||||
|
} from './helpers/nodeHelpers';
|
||||||
import { HistoryManager } from './history-manager';
|
import { HistoryManager } from './history-manager';
|
||||||
|
|
||||||
const logger = createLogger('graph-manager');
|
const logger = createLogger('graph-manager');
|
||||||
@@ -23,41 +30,6 @@ logger.mute();
|
|||||||
|
|
||||||
const remoteRegistry = new RemoteNodeRegistry('');
|
const remoteRegistry = new RemoteNodeRegistry('');
|
||||||
|
|
||||||
const clone = 'structuredClone' in globalThis
|
|
||||||
? globalThis.structuredClone
|
|
||||||
: (args: unknown) => JSON.parse(JSON.stringify(args));
|
|
||||||
|
|
||||||
function areSocketsCompatible(
|
|
||||||
output: string | undefined,
|
|
||||||
inputs: string | (string | undefined)[] | undefined
|
|
||||||
) {
|
|
||||||
if (output === '*') return true;
|
|
||||||
if (Array.isArray(inputs) && output) {
|
|
||||||
return inputs.includes('*') || inputs.includes(output);
|
|
||||||
}
|
|
||||||
return inputs === output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
|
|
||||||
if (firstEdge[0].id !== secondEdge[0].id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstEdge[1] !== secondEdge[1]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstEdge[2].id !== secondEdge[2].id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstEdge[3] !== secondEdge[3]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GraphManager extends EventEmitter<{
|
export class GraphManager extends EventEmitter<{
|
||||||
save: Graph;
|
save: Graph;
|
||||||
result: unknown;
|
result: unknown;
|
||||||
@@ -129,26 +101,11 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
serialize(): Graph {
|
serialize(): Graph {
|
||||||
const nodes = Array.from(this.nodes.values()).map((node) => ({
|
const nodes = Array.from(this.nodes.values()).map((node) => serializeNode(node));
|
||||||
id: node.id,
|
const edges = this.edges.map((edge) => serializeEdge(edge));
|
||||||
position: [...node.position],
|
|
||||||
type: node.type,
|
|
||||||
props: node.props
|
|
||||||
})) as NodeInstance[];
|
|
||||||
const edges = this.edges.map((edge) => [
|
|
||||||
edge[0].id,
|
|
||||||
edge[1],
|
|
||||||
edge[2].id,
|
|
||||||
edge[3]
|
|
||||||
]) as Graph['edges'];
|
|
||||||
|
|
||||||
const groups = this.graph.groups?.map((group) => {
|
const groups = this.graph.groups?.map((group) => {
|
||||||
const groupNodes = group.nodes.map((node) => ({
|
const groupNodes = group.nodes.map((node) => serializeNode(node));
|
||||||
id: node.id,
|
|
||||||
position: [...node.position],
|
|
||||||
type: node.type,
|
|
||||||
props: node.props
|
|
||||||
})) as NodeInstance[];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: group.id,
|
id: group.id,
|
||||||
@@ -814,12 +771,39 @@ export class GraphManager extends EventEmitter<{
|
|||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUnusedGroups() {
|
||||||
|
const usedGroupIds = new SvelteSet<number>();
|
||||||
|
const queue: number[] = [];
|
||||||
|
|
||||||
|
for (const node of this.nodes.values()) {
|
||||||
|
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
|
||||||
|
queue.push(node.props.groupId as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length) {
|
||||||
|
const groupId = queue.pop()!;
|
||||||
|
if (usedGroupIds.has(groupId)) continue;
|
||||||
|
usedGroupIds.add(groupId);
|
||||||
|
const group = this.getGroup(groupId);
|
||||||
|
if (!group) continue;
|
||||||
|
for (const node of group.nodes) {
|
||||||
|
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
|
||||||
|
const childId = node.props.groupId as number;
|
||||||
|
if (!usedGroupIds.has(childId)) queue.push(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.graph.groups.filter(g => !usedGroupIds.has(g.id));
|
||||||
|
}
|
||||||
|
|
||||||
removeUnusedGroups() {
|
removeUnusedGroups() {
|
||||||
const usedGroups = new SvelteSet(this.getAllNodes().map(n => n.props?.groupId));
|
const unused = this.getUnusedGroups();
|
||||||
const unusedGroupAmount = this.graph.groups.length - usedGroups.size;
|
const unusedIds = new SvelteSet(unused.map(g => g.id));
|
||||||
this.graph.groups = this.graph.groups.filter(g => usedGroups.has(g.id));
|
this.graph.groups = this.graph.groups.filter(g => !unusedIds.has(g.id));
|
||||||
this.save();
|
this.save();
|
||||||
return unusedGroupAmount;
|
return unused.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
groupNodes(nodeIds: number[]) {
|
groupNodes(nodeIds: number[]) {
|
||||||
@@ -863,10 +847,14 @@ export class GraphManager extends EventEmitter<{
|
|||||||
inputs[`input_${i}`] = input as NodeInput;
|
inputs[`input_${i}`] = input as NodeInput;
|
||||||
});
|
});
|
||||||
|
|
||||||
const outputs = [groupOutputs.values().next().value!].map((edge, i) => ({
|
const outputs = [];
|
||||||
label: `Output ${i}`,
|
if (groupOutputs.size) {
|
||||||
|
const edge = groupOutputs.values().next().value!;
|
||||||
|
outputs.push({
|
||||||
|
label: `Output`,
|
||||||
type: edge[2].state.type?.inputs?.[edge[3]].type || '*'
|
type: edge[2].state.type?.inputs?.[edge[3]].type || '*'
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const groupPosition = [0, 0] as [number, number];
|
const groupPosition = [0, 0] as [number, number];
|
||||||
const bounds: Box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
|
const bounds: Box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import AddMenu from '../components/AddMenu.svelte';
|
import AddMenu from '../components/AddMenu.svelte';
|
||||||
import BoxSelection from '../components/BoxSelection.svelte';
|
import BoxSelection from '../components/BoxSelection.svelte';
|
||||||
import Camera from '../components/Camera.svelte';
|
import Camera from '../components/Camera.svelte';
|
||||||
|
import GroupBreadcrumps from '../components/GroupBreadcrumps.svelte';
|
||||||
import HelpView from '../components/HelpView.svelte';
|
import HelpView from '../components/HelpView.svelte';
|
||||||
import Debug from '../debug/Debug.svelte';
|
import Debug from '../debug/Debug.svelte';
|
||||||
import EdgeEl from '../edges/Edge.svelte';
|
import EdgeEl from '../edges/Edge.svelte';
|
||||||
@@ -109,13 +110,15 @@
|
|||||||
return nodeType?.outputs?.[index] || 'unknown';
|
return nodeType?.outputs?.[index] || 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGroupName() {
|
let groupSize = 0;
|
||||||
const groupId = graph.graphStack.at(-1)?.groupId;
|
$effect(() => {
|
||||||
if (groupId !== undefined) {
|
if (graph.graph.groups.length > groupSize) {
|
||||||
const group = graph.getGroup(groupId);
|
groupSize = graph.graph.groups.length;
|
||||||
return group?.name || `Group#${groupId}`;
|
|
||||||
}
|
}
|
||||||
|
if (graph.graph.groups.length < groupSize) {
|
||||||
|
console.error('We have lost a group!');
|
||||||
}
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@@ -142,7 +145,6 @@
|
|||||||
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
|
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
|
||||||
{...fileDropEvents.getEventListenerProps()}
|
{...fileDropEvents.getEventListenerProps()}
|
||||||
>
|
>
|
||||||
<div class="shadow"></div>
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/wasm,application/json"
|
accept="application/wasm,application/json"
|
||||||
@@ -153,17 +155,7 @@
|
|||||||
/>
|
/>
|
||||||
<label for="drop-zone"></label>
|
<label for="drop-zone"></label>
|
||||||
|
|
||||||
{#if graph.isInsideGroup}
|
<GroupBreadcrumps />
|
||||||
<button
|
|
||||||
class="exit-group flex items-center gap-1 p-1 text-sm px-2 bg-layer-2"
|
|
||||||
onclick={() => graphState.exitGroupNode()}
|
|
||||||
>
|
|
||||||
<span class="i-[tabler--arrow-left]"></span>Exit Group
|
|
||||||
</button>
|
|
||||||
<p class="group-name absolute">
|
|
||||||
Group <b>{getGroupName()}</b>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
|
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
|
||||||
<Camera
|
<Camera
|
||||||
@@ -273,30 +265,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-name {
|
|
||||||
position: absolute;
|
|
||||||
left: calc(50% - var(--padding-right) / 2);
|
|
||||||
transition: left 0.3s ease;
|
|
||||||
top: 12px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-group {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
left: 12px;
|
|
||||||
z-index: 10;
|
|
||||||
border: 1px solid var(--stroke);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exit-group:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -308,22 +276,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow {
|
|
||||||
position: absolute;
|
|
||||||
top: -5px;
|
|
||||||
left: -5px;
|
|
||||||
right: calc(var(--padding-right) - 5px);
|
|
||||||
bottom: -5px;
|
|
||||||
z-index: 1;
|
|
||||||
transition: box-shadow 0.3s ease;
|
|
||||||
box-shadow: 0 0 0px 0px var(--color-layer-2) inset;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-inside-group .shadow {
|
|
||||||
box-shadow: 0 0 0px 8px var(--color-layer-2) inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-panning {
|
.is-panning {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { NodeDefinition } from '@nodarium/types';
|
import type {
|
||||||
|
Edge,
|
||||||
|
NodeDefinition,
|
||||||
|
NodeInstance,
|
||||||
|
SerializedEdge,
|
||||||
|
SerializedNode
|
||||||
|
} from '@nodarium/types';
|
||||||
|
|
||||||
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
||||||
if (node.id === '__internal/group/input') {
|
if (node.id === '__internal/group/input') {
|
||||||
@@ -27,6 +33,19 @@ export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
|||||||
return 50;
|
return 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function serializeNode(node: SerializedNode | NodeInstance): SerializedNode {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
position: [...node.position],
|
||||||
|
type: node.type,
|
||||||
|
props: node.props
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeEdge(edge: Edge): SerializedEdge {
|
||||||
|
return [edge[0].id, edge[1], edge[2].id, edge[3]];
|
||||||
|
}
|
||||||
|
|
||||||
const nodeHeightCache: Record<string, number> = {};
|
const nodeHeightCache: Record<string, number> = {};
|
||||||
export function getNodeHeight(node: NodeDefinition) {
|
export function getNodeHeight(node: NodeDefinition) {
|
||||||
if (!node || !('inputs' in node)) {
|
if (!node || !('inputs' in node)) {
|
||||||
@@ -45,3 +64,34 @@ export function getNodeHeight(node: NodeDefinition) {
|
|||||||
nodeHeightCache[node.id] = height;
|
nodeHeightCache[node.id] = height;
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function areSocketsCompatible(
|
||||||
|
output: string | undefined,
|
||||||
|
inputs: string | (string | undefined)[] | undefined
|
||||||
|
) {
|
||||||
|
if (output === '*') return true;
|
||||||
|
if (Array.isArray(inputs) && output) {
|
||||||
|
return inputs.includes('*') || inputs.includes(output);
|
||||||
|
}
|
||||||
|
return inputs === output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
|
||||||
|
if (firstEdge[0].id !== secondEdge[0].id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstEdge[1] !== secondEdge[1]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstEdge[2].id !== secondEdge[2].id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstEdge[3] !== secondEdge[3]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||||
import type { NodeInstance } from '@nodarium/types';
|
import type { NodeInstance } from '@nodarium/types';
|
||||||
import InputSelect from '../../../../../packages/ui/src/lib/inputs/InputSelect.svelte';
|
import InputSelect from '../../../../../packages/ui/src/lib/inputs/InputSelect.svelte';
|
||||||
|
import UnusedGroupsPanel from './UnusedGroupsPanel.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
manager: GraphManager;
|
manager: GraphManager;
|
||||||
@@ -28,18 +29,9 @@
|
|||||||
const name = (e.target as HTMLInputElement).value;
|
const name = (e.target as HTMLInputElement).value;
|
||||||
if (activeGroup) manager.renameGroup(activeGroup.id, name);
|
if (activeGroup) manager.renameGroup(activeGroup.id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUnusedGroups = $derived.by(() => {
|
|
||||||
if (!manager) return false;
|
|
||||||
if (manager.isInsideGroup) return false;
|
|
||||||
if (manager.graph.groups.length === 0) return false;
|
|
||||||
return manager.graph.groups.filter(g => {
|
|
||||||
return !manager.graph.nodes.find(n => n.props?.groupId === g.id);
|
|
||||||
}).length;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if activeGroup || hasUnusedGroups}
|
{#if activeGroup}
|
||||||
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
|
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
|
||||||
<h3>Group Settings</h3>
|
<h3>Group Settings</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,12 +68,8 @@
|
|||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasUnusedGroups}
|
{#if manager && !manager.isInsideGroup}
|
||||||
<div class="group-actions">
|
<UnusedGroupsPanel {manager} />
|
||||||
<button onclick={() => manager.removeUnusedGroups()}>
|
|
||||||
Remove ({hasUnusedGroups}) orphaned groups
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -110,26 +98,4 @@
|
|||||||
.group-settings input:focus {
|
.group-settings input:focus {
|
||||||
outline: 1px solid var(--color-active);
|
outline: 1px solid var(--color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-actions {
|
|
||||||
padding: 0.75em 1em;
|
|
||||||
border-bottom: 1px solid var(--color-outline);
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-actions button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.4em 0.8em;
|
|
||||||
background: var(--color-layer-1);
|
|
||||||
border: 1px solid var(--color-outline);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: var(--font-family);
|
|
||||||
font-size: 0.85em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-actions button:hover {
|
|
||||||
border-color: var(--color-active);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||||
|
import type { GroupDefinition } from '@nodarium/types';
|
||||||
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
type Props = { manager: GraphManager };
|
||||||
|
const { manager }: Props = $props();
|
||||||
|
|
||||||
|
type GroupNode = { group: GroupDefinition; children: GroupNode[] };
|
||||||
|
|
||||||
|
const unusedTree = $derived.by((): GroupNode[] => {
|
||||||
|
const unused = manager.getUnusedGroups();
|
||||||
|
if (!unused.length) return [];
|
||||||
|
|
||||||
|
const unusedIds = new Set(unused.map(g => g.id));
|
||||||
|
|
||||||
|
// Build child map: which unused groups reference which other unused groups
|
||||||
|
const childrenOf = new SvelteMap<number, number[]>();
|
||||||
|
const referencedBy = new SvelteSet<number>();
|
||||||
|
|
||||||
|
for (const group of unused) {
|
||||||
|
const refs: number[] = [];
|
||||||
|
for (const node of group.nodes) {
|
||||||
|
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
|
||||||
|
const childId = node.props.groupId as number;
|
||||||
|
if (unusedIds.has(childId)) {
|
||||||
|
refs.push(childId);
|
||||||
|
referencedBy.add(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
childrenOf.set(group.id, refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map(unused.map(g => [g.id, g]));
|
||||||
|
|
||||||
|
function buildNode(g: GroupDefinition): GroupNode {
|
||||||
|
return {
|
||||||
|
group: g,
|
||||||
|
children: (childrenOf.get(g.id) ?? []).map(id => buildNode(byId.get(id)!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return unused
|
||||||
|
.filter(g => !referencedBy.has(g.id))
|
||||||
|
.map(buildNode);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if unusedTree.length}
|
||||||
|
<div class="panel">
|
||||||
|
<div class="header">
|
||||||
|
<span>Unused groups</span>
|
||||||
|
<button class="remove-all" onclick={() => manager.removeUnusedGroups()}>
|
||||||
|
Remove all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="tree">
|
||||||
|
{#snippet treeNode(node: GroupNode)}
|
||||||
|
<li>
|
||||||
|
<span class="group-name">{node.group.name || `Group #${node.group.id}`}</span>
|
||||||
|
{#if node.children.length}
|
||||||
|
<ul>
|
||||||
|
{#each node.children as child (child.group.id)}
|
||||||
|
{@render treeNode(child)}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/snippet}
|
||||||
|
{#each unusedTree as node (node.group.id)}
|
||||||
|
{@render treeNode(node)}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel {
|
||||||
|
border-bottom: 1px solid var(--color-outline);
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-all {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-outline);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.2em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-all:hover {
|
||||||
|
border-color: var(--color-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
border-left: 1px solid var(--color-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree li {
|
||||||
|
padding: 0.15em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree ul .group-name::before {
|
||||||
|
content: '└ ';
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,7 @@ export type {
|
|||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
NodeId,
|
NodeId,
|
||||||
NodeInstance,
|
NodeInstance,
|
||||||
|
SerializedEdge,
|
||||||
SerializedNode,
|
SerializedNode,
|
||||||
Socket
|
Socket
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ export type Socket = {
|
|||||||
|
|
||||||
export type Edge = [NodeInstance, number, NodeInstance, string];
|
export type Edge = [NodeInstance, number, NodeInstance, string];
|
||||||
|
|
||||||
|
const SerializedEdgeSchema = z.tuple([z.number(), z.number(), z.number(), z.string()]);
|
||||||
|
|
||||||
|
export type SerializedEdge = z.infer<typeof SerializedEdgeSchema>;
|
||||||
|
|
||||||
export const GroupSchema = z.object({
|
export const GroupSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@@ -100,7 +104,7 @@ export const GraphSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
settings: z.record(z.string(), z.any()).optional(),
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
nodes: z.array(NodeSchema),
|
nodes: z.array(NodeSchema),
|
||||||
edges: z.array(z.tuple([z.number(), z.number(), z.number(), z.string()])),
|
edges: z.array(SerializedEdgeSchema),
|
||||||
groups: z.array(GroupSchema)
|
groups: z.array(GroupSchema)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user