Skip to content

Commit 59af98d

Browse files
authored
Merge pull request #230 from pathsim/feature/grid-aligned-routing
grid aligned everything
2 parents 25615ca + 68dc11a commit 59af98d

6 files changed

Lines changed: 141 additions & 77 deletions

File tree

src/lib/components/FlowCanvas.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import { nodeUpdatesStore } from '$lib/stores/nodeUpdates';
3030
import { nodeRegistry } from '$lib/nodes';
3131
import { NODE_TYPES } from '$lib/constants/nodeTypes';
32+
import { SNAP_GRID, BACKGROUND_GAP } from '$lib/constants/grid';
3233
import type { NodeInstance, Connection, Annotation } from '$lib/nodes/types';
3334
import type { EventInstance } from '$lib/events/types';
3435
@@ -772,7 +773,7 @@
772773
onnodecontextmenu={handleNodeContextMenu}
773774
onedgecontextmenu={handleEdgeContextMenu}
774775
onpanecontextmenu={handlePaneContextMenu}
775-
{...{ snapToGrid: true, snapGrid: [15, 15] } as any}
776+
{...{ snapToGrid: true, snapGrid: SNAP_GRID } as any}
776777
deleteKeyCode={['Delete', 'Backspace']}
777778
selectionKeyCode={['Shift']}
778779
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
@@ -785,7 +786,7 @@
785786
proOptions={{ hideAttribution: true }}
786787
>
787788
<FlowUpdater pendingUpdates={pendingNodeUpdates} onUpdatesProcessed={clearPendingUpdates} />
788-
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
789+
<Background variant={BackgroundVariant.Dots} gap={BACKGROUND_GAP} size={1} />
789790
</SvelteFlow>
790791
</div>
791792

src/lib/components/nodes/BaseNode.svelte

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte';
1212
import { paramInput } from '$lib/actions/paramInput';
1313
import { plotDataStore } from '$lib/plotting/processing/plotDataStore';
14-
import { NODE } from '$lib/constants/dimensions';
14+
import { NODE, snapTo2G, getPortPositionCalc } from '$lib/constants/dimensions';
1515
import PlotPreview from './PlotPreview.svelte';
1616
1717
interface Props {
@@ -26,6 +26,14 @@
2626
const typeDef = $derived(nodeRegistry.get(data.type));
2727
const category = $derived(typeDef?.category || 'Algebraic');
2828
29+
// Get valid pinned params (filter out any that no longer exist in the type definition)
30+
// Defined early since it's needed for dimension calculations
31+
const validPinnedParams = $derived(() => {
32+
if (!data.pinnedParams?.length || !typeDef) return [];
33+
const paramNames = new Set(typeDef.params.map(p => p.name));
34+
return data.pinnedParams.filter(name => paramNames.has(name));
35+
});
36+
2937
// Recording node hover preview
3038
const isRecordingNode = $derived(category === 'Recording');
3139
let isHovered = $state(false);
@@ -127,17 +135,19 @@
127135
128136
const maxPortsOnSide = $derived(Math.max(data.inputs.length, data.outputs.length));
129137
130-
// For horizontal layout: height grows with ports; for vertical: width grows
131-
const nodeHeight = $derived(isVertical ? NODE.baseHeight : Math.max(NODE.baseHeight, maxPortsOnSide * NODE.portSpacing + 10));
132-
const nodeWidth = $derived(isVertical ? Math.max(NODE.baseWidth, maxPortsOnSide * NODE.portSpacing + 20) : NODE.baseWidth);
133-
134-
// Calculate port positions using percentages for proper centering
135-
function getPortPosition(index: number, total: number): string {
136-
if (total === 1) return '50%';
137-
// Distribute evenly with padding from edges
138-
const percent = ((index + 1) / (total + 1)) * 100;
139-
return `${percent}%`;
140-
}
138+
// Minimum node dimensions based on port count (grid-aligned to 2G)
139+
// Content can expand beyond these minimums
140+
const minPortDimension = $derived(Math.max(1, maxPortsOnSide) * NODE.portSpacing);
141+
const minNodeHeight = $derived(
142+
isVertical
143+
? snapTo2G(NODE.baseHeight)
144+
: snapTo2G(Math.max(NODE.baseHeight, minPortDimension))
145+
);
146+
const minNodeWidth = $derived(
147+
isVertical
148+
? snapTo2G(Math.max(NODE.baseWidth, minPortDimension))
149+
: snapTo2G(NODE.baseWidth)
150+
);
141151
142152
// Check if this is a Subsystem or Interface node (using shapes utility)
143153
const isSubsystemNode = $derived(isSubsystem(data));
@@ -193,13 +203,6 @@
193203
// Custom node color (defaults to pathsim-blue)
194204
const nodeColor = $derived(data.color || 'var(--accent)');
195205
196-
// Get valid pinned params (filter out any that no longer exist in the type definition)
197-
const validPinnedParams = $derived(() => {
198-
if (!data.pinnedParams?.length || !typeDef) return [];
199-
const paramNames = new Set(typeDef.params.map(p => p.name));
200-
return data.pinnedParams.filter(name => paramNames.has(name));
201-
});
202-
203206
// Handle pinned param change
204207
function handlePinnedParamChange(paramName: string, value: string) {
205208
graphStore.updateNodeParams(id, { [paramName]: value });
@@ -282,7 +285,7 @@
282285
class:preview-hovered={showPreview}
283286
class:subsystem-type={isSubsystemType}
284287
data-rotation={rotation}
285-
style="min-width: {nodeWidth}px; min-height: {nodeHeight}px; --node-color: {nodeColor};"
288+
style="min-width: {minNodeWidth}px; min-height: {minNodeHeight}px; --node-color: {nodeColor};"
286289
ondblclick={handleDoubleClick}
287290
onmouseenter={handleMouseEnter}
288291
onmouseleave={handleMouseLeave}
@@ -361,7 +364,7 @@
361364
type="target"
362365
position={inputPosition()}
363366
id={port.id}
364-
style={isVertical ? `left: ${getPortPosition(i, data.inputs.length)};` : `top: ${getPortPosition(i, data.inputs.length)};`}
367+
style={isVertical ? `left: ${getPortPositionCalc(i, data.inputs.length)};` : `top: ${getPortPositionCalc(i, data.inputs.length)};`}
365368
class="handle handle-input"
366369
onmouseenter={(e) => handleInputMouseEnter(e, port)}
367370
onmouseleave={() => handleInputMouseLeave(port)}
@@ -376,7 +379,7 @@
376379
type="source"
377380
position={outputPosition()}
378381
id={port.id}
379-
style={isVertical ? `left: ${getPortPosition(i, data.outputs.length)};` : `top: ${getPortPosition(i, data.outputs.length)};`}
382+
style={isVertical ? `left: ${getPortPositionCalc(i, data.outputs.length)};` : `top: ${getPortPositionCalc(i, data.outputs.length)};`}
380383
class="handle handle-output"
381384
onmouseenter={(e) => handleOutputMouseEnter(e, port)}
382385
onmouseleave={() => handleOutputMouseLeave(port)}
@@ -388,12 +391,14 @@
388391
<style>
389392
.node {
390393
position: relative;
391-
min-width: 90px;
392-
min-height: 36px;
394+
/* Center node on its position point (node center = local origin) */
395+
transform: translate(-50%, -50%);
396+
/* Dimensions set via inline style using grid constants */
397+
display: flex;
398+
flex-direction: column;
393399
background: var(--surface-raised);
394400
border: 1px solid var(--edge);
395401
font-size: 10px;
396-
transition: all 0.15s ease;
397402
overflow: visible;
398403
}
399404
@@ -412,7 +417,8 @@
412417
413418
.shape-diamond {
414419
border-radius: 4px;
415-
transform: rotate(45deg);
420+
/* Compose with center transform */
421+
transform: translate(-50%, -50%) rotate(45deg);
416422
}
417423
418424
.shape-diamond .node-content {
@@ -447,7 +453,7 @@
447453
z-index: 1000 !important;
448454
}
449455
450-
/* Inner wrapper for proper border-radius clipping */
456+
/* Inner wrapper for content */
451457
.node-inner {
452458
border-radius: inherit;
453459
overflow: hidden;

src/lib/components/nodes/EventNode.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
<style>
4646
.event-node {
4747
position: relative;
48+
/* Center event on its position point (center = local origin) */
49+
transform: translate(-50%, -50%);
4850
cursor: pointer;
4951
}
5052

src/lib/constants/dimensions.ts

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,73 @@
11
/**
22
* Dimension constants for nodes, handles, and events
33
* Single source of truth - used by both live canvas (CSS) and SVG export
4+
*
5+
* All dimensions are designed to align with the grid system defined in grid.ts
46
*/
57

6-
/** Node dimension constants */
8+
import { GRID_SIZE, G } from './grid';
9+
10+
/** Node dimension constants (grid-aligned) */
711
export const NODE = {
8-
/** Base width in pixels */
9-
baseWidth: 90,
10-
/** Base height in pixels */
11-
baseHeight: 36,
12-
/** Spacing between ports in pixels */
13-
portSpacing: 18,
12+
/** Base width: 10 grid units = 100px */
13+
baseWidth: G.x10,
14+
/** Base height: 4 grid units = 40px */
15+
baseHeight: G.x4,
16+
/** Spacing between ports: 2 grid units = 20px */
17+
portSpacing: G.x2,
1418
/** Border width in pixels */
1519
borderWidth: 1
1620
} as const;
1721

1822
/** Handle (port connector) dimensions */
1923
export const HANDLE = {
20-
/** Width of horizontal handles (rotation 0, 2) */
21-
width: 10,
24+
/** Width of horizontal handles (rotation 0, 2): 1 grid unit */
25+
width: GRID_SIZE,
2226
/** Height of horizontal handles (rotation 0, 2) */
2327
height: 8,
2428
/** Inset from outer to inner path for hollow effect */
2529
hollowInset: 1.5
2630
} as const;
2731

28-
/** Event node dimensions */
32+
/** Event node dimensions (grid-aligned) */
2933
export const EVENT = {
30-
/** Total bounding box size */
31-
size: 80,
32-
/** Center point (size / 2) */
33-
center: 40,
34+
/** Total bounding box size: 8 grid units = 80px */
35+
size: G.px(8),
3436
/** Diamond shape size (rotated square) */
3537
diamondSize: 56,
3638
/** Diamond offset from center (diamondSize / 2) */
3739
diamondOffset: 28
3840
} as const;
3941

40-
/** Export padding */
41-
export const EXPORT_PADDING = 40;
42+
/** Export padding: 4 grid units = 40px */
43+
export const EXPORT_PADDING = G.x4;
4244

43-
/** Calculate node dimensions based on ports and rotation */
44-
export function calculateNodeDimensions(
45-
inputCount: number,
46-
outputCount: number,
47-
rotation: number
48-
): { width: number; height: number } {
49-
const isVertical = rotation === 1 || rotation === 3;
50-
const maxPorts = Math.max(inputCount, outputCount);
45+
/**
46+
* Round up to next 2G (20px) boundary for symmetric expansion.
47+
* This ensures nodes expand evenly from center.
48+
*/
49+
export function snapTo2G(value: number): number {
50+
return Math.ceil(value / G.x2) * G.x2;
51+
}
5152

52-
if (isVertical) {
53-
return {
54-
width: Math.max(NODE.baseWidth, maxPorts * NODE.portSpacing + 20),
55-
height: NODE.baseHeight
56-
};
53+
/**
54+
* Calculate port position as CSS calc() expression.
55+
* Uses offset from center to ensure grid alignment regardless of node size,
56+
* since the node center is always at a grid-aligned position.
57+
*
58+
* @param index - Port index (0-based)
59+
* @param total - Total number of ports on this edge
60+
* @returns CSS position value (e.g., "50%" or "calc(50% + 10px)")
61+
*/
62+
export function getPortPositionCalc(index: number, total: number): string {
63+
if (total <= 0 || total === 1) {
64+
return '50%'; // Single port at center
5765
}
58-
59-
return {
60-
width: NODE.baseWidth,
61-
height: Math.max(NODE.baseHeight, maxPorts * NODE.portSpacing + 10)
62-
};
66+
// For N ports with spacing S: span = (N-1)*S, offset from center = -span/2 + i*S
67+
const span = (total - 1) * NODE.portSpacing;
68+
const offsetFromCenter = -span / 2 + index * NODE.portSpacing;
69+
if (offsetFromCenter === 0) {
70+
return '50%';
71+
}
72+
return `calc(50% + ${offsetFromCenter}px)`;
6373
}

src/lib/constants/grid.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Unified Grid System
3+
*
4+
* Single source of truth for all grid-related dimensions.
5+
* All node, port, and edge positioning should align to this grid.
6+
*
7+
* Design principle:
8+
* - Base grid unit: 10px
9+
* - Port spacing: 2 grid units (20px) - works for both even and odd port counts
10+
* - Node dimensions: multiples of grid unit
11+
* - Edges route along grid lines
12+
*/
13+
14+
/** Base grid unit in pixels */
15+
export const GRID_SIZE = 10;
16+
17+
/** Grid calculation helpers */
18+
export const G = {
19+
/** Base unit in pixels */
20+
unit: GRID_SIZE,
21+
/** 2 grid units - standard port spacing */
22+
x2: GRID_SIZE * 2,
23+
/** 4 grid units - standard node base height */
24+
x4: GRID_SIZE * 4,
25+
/** 10 grid units - standard node width */
26+
x10: GRID_SIZE * 10,
27+
/** Convert grid units to pixels */
28+
px: (units: number) => units * GRID_SIZE
29+
} as const;
30+
31+
/** SvelteFlow snap grid configuration [x, y] */
32+
export const SNAP_GRID: [number, number] = [GRID_SIZE, GRID_SIZE];
33+
34+
/** Background dot/grid spacing (2G for visual clarity) */
35+
export const BACKGROUND_GAP = G.x2;

src/lib/export/svg/renderer.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { get } from 'svelte/store';
1212
import { graphStore } from '$lib/stores/graph';
1313
import { eventStore } from '$lib/stores/events';
1414
import { getThemeColors } from '$lib/constants/theme';
15-
import { EVENT } from '$lib/constants/dimensions';
15+
import { NODE, EVENT } from '$lib/constants/dimensions';
1616
import { getHandlePath } from '$lib/constants/handlePaths';
1717
import type { ExportOptions, RenderContext, Bounds } from './types';
1818
import { DEFAULT_OPTIONS } from './types';
@@ -132,14 +132,17 @@ function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: Render
132132
// ============================================================================
133133

134134
function renderNode(node: NodeInstance, ctx: RenderContext): string {
135-
const { x, y } = node.position;
136135
const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement;
137136
if (!wrapper) return '';
138137

139138
const dims = getNodeDimensions(node.id);
140139
if (!dims) return '';
141140
const { width, height } = dims;
142141

142+
// Position is center-origin, convert to top-left for SVG
143+
const x = node.position.x - width / 2;
144+
const y = node.position.y - height / 2;
145+
143146
const nodeEl = wrapper.querySelector('.node') as HTMLElement;
144147
if (!nodeEl) return '';
145148

@@ -217,8 +220,9 @@ function renderEvent(event: EventInstance, ctx: RenderContext): string {
217220
eventType = typeEl?.textContent || '';
218221
}
219222

220-
const cx = event.position.x + EVENT.center;
221-
const cy = event.position.y + EVENT.center;
223+
// Position is center-origin, so position IS the center
224+
const cx = event.position.x;
225+
const cy = event.position.y;
222226
const color = event.color || ctx.theme.accent;
223227

224228
const parts: string[] = [];
@@ -261,19 +265,25 @@ function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds
261265

262266
for (const node of nodes) {
263267
const dims = getNodeDimensions(node.id);
264-
const width = dims?.width ?? 90;
265-
const height = dims?.height ?? 36;
266-
bounds.minX = Math.min(bounds.minX, node.position.x);
267-
bounds.minY = Math.min(bounds.minY, node.position.y);
268-
bounds.maxX = Math.max(bounds.maxX, node.position.x + width);
269-
bounds.maxY = Math.max(bounds.maxY, node.position.y + height);
268+
const width = dims?.width ?? NODE.baseWidth;
269+
const height = dims?.height ?? NODE.baseHeight;
270+
// Position is center-origin, calculate corners
271+
const left = node.position.x - width / 2;
272+
const top = node.position.y - height / 2;
273+
bounds.minX = Math.min(bounds.minX, left);
274+
bounds.minY = Math.min(bounds.minY, top);
275+
bounds.maxX = Math.max(bounds.maxX, left + width);
276+
bounds.maxY = Math.max(bounds.maxY, top + height);
270277
}
271278

272279
for (const event of events) {
273-
bounds.minX = Math.min(bounds.minX, event.position.x);
274-
bounds.minY = Math.min(bounds.minY, event.position.y);
275-
bounds.maxX = Math.max(bounds.maxX, event.position.x + EVENT.size);
276-
bounds.maxY = Math.max(bounds.maxY, event.position.y + EVENT.size);
280+
// Events also use center-origin
281+
const left = event.position.x - EVENT.size / 2;
282+
const top = event.position.y - EVENT.size / 2;
283+
bounds.minX = Math.min(bounds.minX, left);
284+
bounds.minY = Math.min(bounds.minY, top);
285+
bounds.maxX = Math.max(bounds.maxX, left + EVENT.size);
286+
bounds.maxY = Math.max(bounds.maxY, top + EVENT.size);
277287
}
278288

279289
return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 };

0 commit comments

Comments
 (0)