diff --git a/modules/masonry/src/brick/@types/brick.d.ts b/modules/masonry/src/@types/brick.d.ts similarity index 98% rename from modules/masonry/src/brick/@types/brick.d.ts rename to modules/masonry/src/@types/brick.d.ts index a098dd2f..db8586a8 100644 --- a/modules/masonry/src/brick/@types/brick.d.ts +++ b/modules/masonry/src/@types/brick.d.ts @@ -37,7 +37,7 @@ type TBrickRenderProps = { colorBg: TColor; colorFg: TColor; strokeColor: TColor; - strokeWidth: number; //remove + strokeWidth: number; scale: number; shadow: boolean; tooltip?: string; diff --git a/modules/masonry/src/@types/tower.d.ts b/modules/masonry/src/@types/tower.d.ts new file mode 100644 index 00000000..819ef174 --- /dev/null +++ b/modules/masonry/src/@types/tower.d.ts @@ -0,0 +1,23 @@ +/** A simple Cartesian point */ +export type TPoint = { x: number; y: number }; + +/** All supported physical notch pairings */ +export type TNotchType = 'top-bottom' | 'right-left' | 'left-right' | 'nested'; + +/** A single connection‑point centroid */ +export type TConnectionPoint = { x: number; y: number }; + +/** One logical connection between two bricks */ +export type TBrickConnection = { + from: string; // uuid of source brick + to: string; // uuid of destination brick + fromNotchId: string; // e.g. "right_0" + toNotchId: string; // e.g. "left" + type: TNotchType; +}; + +/** Validation result for attempted connections */ +export type TConnectionValidation = { + isValid: boolean; + reason?: string; +}; diff --git a/modules/masonry/src/brick/model/model.ts b/modules/masonry/src/brick/model/model.ts index 87a3994f..c8f31a7e 100644 --- a/modules/masonry/src/brick/model/model.ts +++ b/modules/masonry/src/brick/model/model.ts @@ -11,10 +11,11 @@ import type { TBrickRenderPropsExpression, IBrickCompound, TBrickRenderPropsCompound, -} from '../@types/brick'; +} from '../../@types/brick'; import type { TConnectionPoints as TCP } from '../../tree/model/model'; import { generateBrickData } from '../utils/path'; import type { TInputUnion } from '../utils/path'; +import { getLabelWidth } from '../utils/textMeasurement'; export abstract class BrickModel implements IBrick { protected _uuid: string; @@ -134,6 +135,22 @@ export abstract class BrickModel implements IBrick { /** Must assemble the full render props for this brick. */ public abstract get renderProps(): TBrickRenderProps; + + /** Must update geometry when label or other properties change. */ + public abstract updateGeometry(): void; + + get label(): string { + return this._label; + } + + set label(value: string) { + this._label = value; + this.updateGeometry(); + } + + get labelType(): 'text' | 'glyph' | 'icon' | 'thumbnail' { + return this._labelType; + } } /** @@ -184,14 +201,12 @@ export class SimpleBrick extends BrickModel implements IBrickSimple { type: 'type1', strokeWidth: this._strokeWidth, scaleFactor: this._scale, - bBoxLabel: { w: this._label.length * 8, h: 20 }, + bBoxLabel: { w: getLabelWidth(this._label), h: 20 }, bBoxArgs: this._bboxArgs, hasNotchAbove: this._topNotch, hasNotchBelow: this._bottomNotch, }; const data = generateBrickData(config); - - // use the public setters this.connectionPoints = data.connectionPoints; this.boundingBox = data.boundingBox; } @@ -260,7 +275,7 @@ export class ExpressionBrick extends BrickModel implements IBrickExpression { type: 'type2', strokeWidth: this._strokeWidth, scaleFactor: this._scale, - bBoxLabel: { w: this._label.length * 8, h: 20 }, + bBoxLabel: { w: getLabelWidth(this._label), h: 20 }, bBoxArgs: this._bboxArgs, }; const data = generateBrickData(config); @@ -341,7 +356,7 @@ export default class CompoundBrick extends BrickModel implements IBrickCompound type: 'type3', strokeWidth: this._strokeWidth, scaleFactor: this._scale, - bBoxLabel: { w: this._label.length * 8, h: 20 }, + bBoxLabel: { w: getLabelWidth(this._label), h: 20 }, bBoxArgs: this._bboxArgs, hasNotchAbove: this._topNotch, hasNotchBelow: this._bottomNotch, @@ -372,6 +387,33 @@ export default class CompoundBrick extends BrickModel implements IBrickCompound this._bboxNest = extents; } + /** + * Recursively update bounding box and connection points to fit nested children. + * Call this after all children are attached, before rendering. + */ + public updateLayoutWithChildren(nestedChildren: BrickModel[]): void { + // If there are nested children, calculate the total bounding box + if (nestedChildren && nestedChildren.length > 0) { + // Calculate the bounding box that fits all nested children + let _minX = 0, + _minY = 0, + maxX = 0, + maxY = 0; + nestedChildren.forEach((child) => { + const bbox = child.boundingBox; + // For simplicity, assume children are stacked vertically for now + maxY += bbox.h; + maxX = Math.max(maxX, bbox.w); + }); + // Expand this brick's bboxNest to fit the children + this._bboxNest = [{ w: maxX, h: maxY }]; + } else { + this._bboxNest = []; + } + // Update geometry with new bboxNest + this.updateGeometry(); + } + public override get renderProps(): TBrickRenderPropsCompound { return { ...this.getCommonRenderProps(), diff --git a/modules/masonry/src/brick/utils/brickFactory.ts b/modules/masonry/src/brick/utils/brickFactory.ts new file mode 100644 index 00000000..cbea1e1f --- /dev/null +++ b/modules/masonry/src/brick/utils/brickFactory.ts @@ -0,0 +1,133 @@ +// brickFactory.ts +import { SimpleBrick, ExpressionBrick } from '../model/model'; +import CompoundBrick from '../model/model'; +import type { TBrickType, TColor, TExtent } from '../../@types/brick'; + +let idCounter = 0; +function generateUUID(prefix: string): string { + return `${prefix}_${++idCounter}`; +} + +// Default colors +const defaultColors = { + simple: { + colorBg: '#bbdefb' as TColor, + colorFg: '#222' as TColor, + strokeColor: '#1976d2' as TColor, + }, + expression: { + colorBg: '#b2fab4' as TColor, + colorFg: '#222' as TColor, + strokeColor: '#2e7d32' as TColor, + }, + compound: { + colorBg: '#b9f6ca' as TColor, + colorFg: '#222' as TColor, + strokeColor: '#43a047' as TColor, + }, +}; + +const defaultLabelType = 'text' as const; +const defaultScale = 1; +const defaultBBoxArgs: TExtent[] = [{ w: 40, h: 20 }]; + +export function createSimpleBrick( + overrides: Partial[0]> = {}, +) { + const idx = idCounter + 1; + // By default, SimpleBrick has two argument slots (for arguments/inputs) + return new SimpleBrick({ + uuid: generateUUID('simple'), + name: overrides.name ?? `Simple${idx}`, + label: overrides.label ?? `Simple${idx}`, + labelType: overrides.labelType ?? defaultLabelType, + colorBg: overrides.colorBg ?? defaultColors.simple.colorBg, + colorFg: overrides.colorFg ?? defaultColors.simple.colorFg, + strokeColor: overrides.strokeColor ?? defaultColors.simple.strokeColor, + shadow: overrides.shadow ?? false, + scale: overrides.scale ?? defaultScale, + bboxArgs: overrides.bboxArgs ?? [ + { w: 40, h: 20 }, + { w: 40, h: 20 }, + ], + topNotch: overrides.topNotch ?? true, + bottomNotch: overrides.bottomNotch ?? true, + tooltip: overrides.tooltip, + ...overrides, + }); +} + +// ExpressionBrick is used as an argument value, not as an argument-receiving brick +export function createExpressionBrick( + overrides: Partial[0]> = {}, +) { + const idx = idCounter + 1; + return new ExpressionBrick({ + uuid: generateUUID('expr'), + name: overrides.name ?? `Expr${idx}`, + label: overrides.label ?? `Expr${idx}`, + labelType: overrides.labelType ?? defaultLabelType, + colorBg: overrides.colorBg ?? defaultColors.expression.colorBg, + colorFg: overrides.colorFg ?? defaultColors.expression.colorFg, + strokeColor: overrides.strokeColor ?? defaultColors.expression.strokeColor, + shadow: overrides.shadow ?? false, + scale: overrides.scale ?? defaultScale, + bboxArgs: overrides.bboxArgs ?? [{ w: 40, h: 20 }], + value: overrides.value, + isValueSelectOpen: overrides.isValueSelectOpen ?? false, + tooltip: overrides.tooltip, + ...overrides, + }); +} + +export function createCompoundBrick( + overrides: Partial[0]> = {}, +) { + const idx = idCounter + 1; + return new CompoundBrick({ + uuid: generateUUID('compound'), + name: overrides.name ?? `Compound${idx}`, + label: overrides.label ?? `Compound${idx}`, + labelType: overrides.labelType ?? defaultLabelType, + colorBg: overrides.colorBg ?? defaultColors.compound.colorBg, + colorFg: overrides.colorFg ?? defaultColors.compound.colorFg, + strokeColor: overrides.strokeColor ?? defaultColors.compound.strokeColor, + shadow: overrides.shadow ?? false, + scale: overrides.scale ?? defaultScale, + bboxArgs: overrides.bboxArgs ?? defaultBBoxArgs, + bboxNest: overrides.bboxNest ?? [], + topNotch: overrides.topNotch ?? true, + bottomNotch: overrides.bottomNotch ?? true, + isFolded: overrides.isFolded ?? false, + tooltip: overrides.tooltip, + ...overrides, + }); +} + +export function resetFactoryCounter() { + idCounter = 0; +} + +export function getFactoryCounter() { + return idCounter; +} + +export function createBrick( + type: TBrickType, + overrides: Partial< + | ConstructorParameters[0] + | ConstructorParameters[0] + | ConstructorParameters[0] + > = {}, +) { + switch (type) { + case 'Simple': + return createSimpleBrick(overrides); + case 'Expression': + return createExpressionBrick(overrides); + case 'Compound': + return createCompoundBrick(overrides); + default: + throw new Error(`Unsupported brick type: ${type}`); + } +} diff --git a/modules/masonry/src/brick/utils/index.ts b/modules/masonry/src/brick/utils/index.ts new file mode 100644 index 00000000..26317917 --- /dev/null +++ b/modules/masonry/src/brick/utils/index.ts @@ -0,0 +1,16 @@ +export { + createSimpleBrick, + createExpressionBrick, + createCompoundBrick, + createBrick, + resetFactoryCounter, + getFactoryCounter, +} from './brickFactory'; +export { generateBrickData } from './path'; +export type { TInputUnion } from './path'; +export { + measureTextWidth, + estimateTextWidth, + getLabelWidth, + measureLabel, +} from './textMeasurement'; diff --git a/modules/masonry/src/brick/utils/path.ts b/modules/masonry/src/brick/utils/path.ts index 405411c1..f9636997 100644 --- a/modules/masonry/src/brick/utils/path.ts +++ b/modules/masonry/src/brick/utils/path.ts @@ -139,7 +139,8 @@ function _generateRight(config: { if (hasArgs) { const requiredMinimum = strokeWidth / 2 + Math.max(MIN_LABEL_HEIGHT, bBoxLabel.h) + strokeWidth / 2; - const argHeightsSum = bBoxArgs.reduce((sum, arg) => sum + arg.h, 0); + const argHeightsSum = + bBoxArgs.length > 0 ? bBoxArgs.reduce((sum, arg) => sum + arg.h, 0) : 0; const extra = Math.max(0, requiredMinimum - argHeightsSum); for (let i = 0; i < bBoxArgs.length; i++) { @@ -263,7 +264,7 @@ function _generateNestedPath(config: { `a ${CORNER_RADIUS} ${CORNER_RADIUS} 90 0 1 ${CORNER_RADIUS} ${CORNER_RADIUS}`, ...(hasSecondaryLabel ? [`v ${labelHeight}`] : ['v 4']), `a ${CORNER_RADIUS} ${CORNER_RADIUS} 90 0 1 -${CORNER_RADIUS} ${CORNER_RADIUS}`, - `h -${variableOuterWidth}`, + `h -${variableOuterWidth - 2 * strokeWidth}`, ...(hasNotch ? _generateNotchBottom(strokeWidth) : [`h -${WIDTH_NOTCH_BOTTOM}`]), `h -${OFFSET_NOTCH_BOTTOM}`, ]; @@ -399,7 +400,7 @@ function generatePath(config: TInputType1 | TInputType2 | TInputType3): { const segments = [...top, ...right, ...bottom, ...left]; return { - path: ['M 0,0', ...segments].join(' '), + path: ['m 0,0', ...segments].join(' '), leftEdge: leftEdge, }; } @@ -433,7 +434,7 @@ function getBoundingBox(config: TInputUnion): TBBox { hasArgs, strokeWidth, bBoxLabel, - bBoxArgs, + bBoxArgs: bBoxArgs || [], }); let height = rightVertical + CORNER_RADIUS + (type !== 'type3' ? strokeWidth / 2 : 0); @@ -442,7 +443,11 @@ function getBoundingBox(config: TInputUnion): TBBox { const { bBoxNesting, secondaryLabel } = config as TInputType3; // Reuse nested path logic - let nestingHeight = bBoxNesting.reduce((sum, box) => sum + box.h, 0); + const bBoxNesting3 = bBoxNesting || []; + let nestingHeight = + bBoxNesting3.length > 0 + ? bBoxNesting3.reduce((sum: number, box: TBBox) => sum + box.h, 0) + : 0; nestingHeight = Math.max(nestingHeight, MIN_NESTED_HEIGHT); const labelHeight = Math.max(MIN_LABEL_HEIGHT, bBoxLabel.h); @@ -509,7 +514,7 @@ function getBottomCentroid( // Calculate centroids for right connector notch function getRightCentroids(config: TInputUnion, boundingBox: TBBox): TCentroid[] { - const { bBoxArgs, bBoxLabel, strokeWidth } = config; + const { bBoxArgs = [], bBoxLabel, strokeWidth } = config; // No argument = no right notches if (!bBoxArgs.length) return []; @@ -518,7 +523,7 @@ function getRightCentroids(config: TInputUnion, boundingBox: TBBox): TCentroid[] const labelHeight = Math.max(MIN_LABEL_HEIGHT, bBoxLabel.h); const requiredMinimum = strokeWidth / 2 + labelHeight + strokeWidth / 2; - const argHeightsSum = bBoxArgs.reduce((sum, arg) => sum + arg.h, 0); + const argHeightsSum = bBoxArgs.length > 0 ? bBoxArgs.reduce((sum, arg) => sum + arg.h, 0) : 0; const extra = Math.max(0, requiredMinimum - argHeightsSum); @@ -549,7 +554,7 @@ function getRightCentroids(config: TInputUnion, boundingBox: TBBox): TCentroid[] const centroidY = verticalOffset + 6; centroids.push({ - x: boundingBox.w - 6, // notch is 12 wide, so center is at 6 from right edge + x: boundingBox.w - 5, // notch is 8 wide, so center is at 4 from right edge + strokewidth/2 y: centroidY, }); @@ -604,15 +609,58 @@ export function generateBrickData(config: TInputType1 | TInputType2 | TInputType right: TCentroid[]; bottom?: TCentroid; left?: TCentroid; + args?: { x: number; y: number }[]; + nested?: { x: number; y: number }; }; } { const { path, leftEdge } = generatePath(config); const boundingBox = getBoundingBox(config); const connectionPoints = getConnectionPoints(config, boundingBox, leftEdge); + // Argument slot origins (for all types with args) + let args: { x: number; y: number }[] | undefined = undefined; + if (connectionPoints.right && connectionPoints.right.length > 0) { + args = connectionPoints.right.map((pt) => ({ x: pt.x, y: pt.y })); + // Calculate origin for the argument brick based on the connection coordinates + args.forEach((pt) => { + pt.x = pt.x + OFFSET_NOTCH_RIGHT / 2 + CORNER_RADIUS; // + strokewidth at the end + pt.y = pt.y - CORNER_RADIUS - HEIGHT_NOTCH_RIGHT / 2; + }); + } + + //nesting height to calculate the nested origin, either here or use it from the getBoundingBox function + const bBoxNesting = 'bBoxNesting' in config ? config.bBoxNesting : []; + let nestingHeight = + bBoxNesting.length > 0 + ? bBoxNesting.reduce((sum: number, box: TBBox) => sum + box.h, 0) + : 0; + nestingHeight = Math.max(nestingHeight, MIN_NESTED_HEIGHT); + + // Nested region origin (for type3/compound) + let nested: { x: number; y: number } | undefined = undefined; + if (config.type === 'type3' && connectionPoints.bottom) { + nestingHeight += CORNER_RADIUS + 2; + nested = { + x: + connectionPoints.bottom.x - + WIDTH_NOTCH_BOTTOM / 2 - + OFFSET_NOTCH_BOTTOM - + CORNER_RADIUS - + 2, //strokewidth, + y: connectionPoints.bottom.y - CORNER_RADIUS * 2 - 4 - nestingHeight, + }; + } + return { path, boundingBox, - connectionPoints, + connectionPoints: { + top: connectionPoints.top, + right: connectionPoints.right, + bottom: connectionPoints.bottom, + left: connectionPoints.left, + args, + nested, + }, }; } diff --git a/modules/masonry/src/brick/utils/textMeasurement.ts b/modules/masonry/src/brick/utils/textMeasurement.ts new file mode 100644 index 00000000..7fe56e80 --- /dev/null +++ b/modules/masonry/src/brick/utils/textMeasurement.ts @@ -0,0 +1,52 @@ +/** + * Text measurement utilities for brick rendering + */ + +/** + * Measure text width and height using canvas API + */ +export function measureTextWidth(text: string, fontSize: number = 16): number { + // Create a canvas element for text measurement + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d')!; + context.font = `${fontSize}px sans-serif`; + return context.measureText(text).width; +} + +/** + * Fallback for server-side rendering or when canvas is not available + */ +export function estimateTextWidth(text: string): number { + return Math.max(text.length * 8, 40); // Minimum width of 40 +} + +/** + * Get label width with padding, with fallback for SSR + */ +export function getLabelWidth(label: string): number { + try { + return measureTextWidth(label, 16) + 8; + } catch { + return estimateTextWidth(label); + } +} + +/** + * Comprehensive label measurement including width, height, ascent, and descent + */ +export function measureLabel(label: string, fontSize: number) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + ctx.font = `${fontSize}px sans-serif`; + const m = ctx.measureText(label); + const ascent = m.actualBoundingBoxAscent ?? fontSize * 0.8; + const descent = m.actualBoundingBoxDescent ?? fontSize * 0.2; + const height = ascent + descent; + + return { + w: m.width + 8, + h: height, + ascent, + descent, + }; +} diff --git a/modules/masonry/src/brick/view/components/BrickWrapper.tsx b/modules/masonry/src/brick/view/components/BrickWrapper.tsx new file mode 100644 index 00000000..09fcbc77 --- /dev/null +++ b/modules/masonry/src/brick/view/components/BrickWrapper.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import type { TBrickRenderProps } from '../../@types/brick'; +import { generateBrickData } from '../../utils/path'; +import type { TInputUnion } from '../../utils/path'; +import { measureLabel } from '../../utils/textMeasurement'; +import { FONT_HEIGHT, PADDING, toCssColor, type TConnectionPoints } from '../utils/common'; + +export interface BrickWrapperProps extends TBrickRenderProps { + /** Callback to receive metrics after calculation */ + RenderMetrics?: (bbox: { w: number; h: number }, connectionPoints: TConnectionPoints) => void; + /** Children to render inside the brick */ + children?: React.ReactNode; + /** Brick-specific configuration for path generation */ + getBrickConfig: (bBoxLabel: { w: number; h: number }) => TInputUnion; +} + +/** + * Reusable wrapper component that handles common brick rendering logic + * Used by SimpleBrickView, ExpressionBrickView, and CompoundBrickView + */ +export const BrickWrapper: React.FC = ({ + label, + labelType, + colorBg, + colorFg, + strokeColor, + strokeWidth, + scale, + shadow, + tooltip, + bboxArgs, + visualState, + isActionMenuOpen, + isVisible, + RenderMetrics, + children, + getBrickConfig, +}) => { + const bBoxLabel = useMemo(() => { + const { w: labelW, h: labelH } = measureLabel(label, FONT_HEIGHT); + return { w: labelW, h: labelH }; + }, [label]); + + const [shape, setShape] = useState<{ path: string; w: number; h: number }>(() => { + const cfg = getBrickConfig(bBoxLabel); + const brickData = generateBrickData(cfg); + return { + path: brickData.path, + w: brickData.boundingBox.w, + h: brickData.boundingBox.h, + }; + }); + + useEffect(() => { + const cfg = getBrickConfig(bBoxLabel); + const brickData = generateBrickData(cfg); + + if (RenderMetrics) { + RenderMetrics(brickData.boundingBox, brickData.connectionPoints); + } + + setShape({ + path: brickData.path, + w: brickData.boundingBox.w, + h: brickData.boundingBox.h, + }); + }, [label, strokeWidth, scale, bboxArgs, bBoxLabel, RenderMetrics, getBrickConfig]); + + if (!isVisible) return null; + + const svgWidth = shape.w + PADDING.left + PADDING.right; + const svgHeight = shape.h + PADDING.top + PADDING.bottom; + + return ( + + + + {labelType === 'text' && ( + + {label} + + )} + {children} + + {tooltip && {tooltip}} + + ); +}; diff --git a/modules/masonry/src/brick/view/components/compound.tsx b/modules/masonry/src/brick/view/components/compound.tsx index f0d5e894..2ca55fe0 100644 --- a/modules/masonry/src/brick/view/components/compound.tsx +++ b/modules/masonry/src/brick/view/components/compound.tsx @@ -1,46 +1,7 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React from 'react'; import type { TBrickRenderPropsCompound } from '../../@types/brick'; -import { generateBrickData } from '../../utils/path'; - -const FONT_HEIGHT = 16; - -const PADDING = { - top: 4, - right: 8, - bottom: 4, - left: 8, -}; - -function toCssColor(color: string | ['rgb' | 'hsl', number, number, number]) { - if (typeof color === 'string') return color; - const [mode, a, b, c] = color; - return mode === 'rgb' ? `rgb(${a},${b},${c})` : `hsl(${a},${b}%,${c}%)`; -} - -function measureLabel(label: string, fontSize: number) { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - ctx.font = `${fontSize}px sans-serif`; - - const m = ctx.measureText(label); - const ascent = m.actualBoundingBoxAscent ?? fontSize * 0.8; - const descent = m.actualBoundingBoxDescent ?? fontSize * 0.2; - const height = ascent + descent; - - return { - w: m.width + 8, - h: height, - ascent, - descent, - }; -} - -type TConnectionPoints = { - top?: { x: number; y: number }; - right: { x: number; y: number }[]; - bottom?: { x: number; y: number }; - left?: { x: number; y: number }; -}; +import { BrickWrapper } from './BrickWrapper'; +import type { TConnectionPoints } from '../utils/common'; type PropsWithMetrics = TBrickRenderPropsCompound & { RenderMetrics?: (bbox: { w: number; h: number }, connectionPoints: TConnectionPoints) => void; @@ -48,124 +9,48 @@ type PropsWithMetrics = TBrickRenderPropsCompound & { export const CompoundBrickView: React.FC = (props) => { const { - label, - labelType, - colorBg, - colorFg, - strokeColor, - strokeWidth, - scale, - shadow, - tooltip, - bboxArgs, - visualState, - isActionMenuOpen, - isVisible, topNotch, bottomNotch, bboxNest, isFolded, - RenderMetrics, - } = props; - - // Memoize bBoxLabel to prevent unnecessary recalculations - const bBoxLabel = useMemo(() => { - const { w: labelW, h: labelH } = measureLabel(label, FONT_HEIGHT); - return { w: labelW, h: labelH }; - }, [label]); - - const bBoxNesting = bboxNest; - - const [shape, setShape] = useState<{ path: string; w: number; h: number }>(() => { - const cfg = { - type: 'type3' as const, - strokeWidth, - scaleFactor: scale, - bBoxLabel, - bBoxArgs: bboxArgs, - hasNotchAbove: topNotch, - hasNotchBelow: bottomNotch, - bBoxNesting, - secondaryLabel: !isFolded, - }; - const brickData = generateBrickData(cfg); - return { - path: brickData.path, - w: brickData.boundingBox.w, - h: brickData.boundingBox.h, - }; - }); - - useEffect(() => { - const cfg = { - type: 'type3' as const, - strokeWidth, - scaleFactor: scale, - bBoxLabel, - bBoxArgs: bboxArgs, - hasNotchAbove: topNotch, - hasNotchBelow: bottomNotch, - bBoxNesting, - secondaryLabel: !isFolded, - }; - const brickData = generateBrickData(cfg); - if (RenderMetrics) { - RenderMetrics(brickData.boundingBox, brickData.connectionPoints); - } - setShape({ path: brickData.path, w: brickData.boundingBox.w, h: brickData.boundingBox.h }); - }, [ - label, strokeWidth, scale, bboxArgs, - topNotch, - bottomNotch, - bboxNest, - isFolded, - bBoxLabel, - bBoxNesting, - ]); - - if (!isVisible) return null; + ...commonProps + } = props; - const svgWidth = shape.w + PADDING.left + PADDING.right; - const svgHeight = shape.h + PADDING.top + PADDING.bottom; + const getBrickConfig = (bBoxLabel: { w: number; h: number }) => ({ + type: 'type3' as const, + strokeWidth, + scaleFactor: scale, + bBoxLabel, + bBoxArgs: bboxArgs, + hasNotchAbove: topNotch, + hasNotchBelow: bottomNotch, + bBoxNesting: bboxNest, + secondaryLabel: true, + }); return ( - - - {/* Brick outline */} - - - {/* Text label */} - {labelType === 'text' && ( - - {label} - - )} - - {/* Tooltip */} - {tooltip && {tooltip}} - - + {/* Compound-specific content */} + {isFolded && ( + + [...] + + )} + ); }; diff --git a/modules/masonry/src/brick/view/components/expression.tsx b/modules/masonry/src/brick/view/components/expression.tsx index 314dba98..48fa1502 100644 --- a/modules/masonry/src/brick/view/components/expression.tsx +++ b/modules/masonry/src/brick/view/components/expression.tsx @@ -1,146 +1,46 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React from 'react'; import type { TBrickRenderPropsExpression } from '../../@types/brick'; -import { generateBrickData } from '../../utils/path'; - -const FONT_HEIGHT = 16; - -const PADDING = { - top: 4, - right: 8, - bottom: 4, - left: 8, -}; - -function toCssColor(color: string | ['rgb' | 'hsl', number, number, number]) { - if (typeof color === 'string') return color; - const [mode, a, b, c] = color; - return mode === 'rgb' ? `rgb(${a},${b},${c})` : `hsl(${a},${b}%,${c}%)`; -} - -function measureLabel(label: string, fontSize: number) { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - ctx.font = `${fontSize}px sans-serif`; - - const m = ctx.measureText(label); - const ascent = m.actualBoundingBoxAscent ?? fontSize * 0.8; - const descent = m.actualBoundingBoxDescent ?? fontSize * 0.2; - - return { - w: m.width + 8, - h: ascent + descent, - ascent, - descent, - }; -} - -type TConnectionPoints = { - top?: { x: number; y: number }; - right: { x: number; y: number }[]; - bottom?: { x: number; y: number }; - left?: { x: number; y: number }; -}; +import { BrickWrapper } from './BrickWrapper'; +import type { TConnectionPoints } from '../utils/common'; type PropsWithMetrics = TBrickRenderPropsExpression & { RenderMetrics?: (bbox: { w: number; h: number }, connectionPoints: TConnectionPoints) => void; }; export const ExpressionBrickView: React.FC = (props) => { - const { - label, - labelType, - colorBg, - colorFg, - strokeColor, - strokeWidth, - scale, - shadow, - tooltip, - bboxArgs, - visualState, - isActionMenuOpen, - isVisible, - RenderMetrics, - // value, isValueSelectOpen, // if needed later - } = props; - - // Memoize bBoxLabel to prevent unnecessary recalculations - const bBoxLabel = useMemo(() => { - const { w: labelW, h: labelH } = measureLabel(label, FONT_HEIGHT); - return { w: labelW, h: labelH }; - }, [label]); + const { value, isValueSelectOpen, strokeWidth, scale, bboxArgs, ...commonProps } = props; - const [shape, setShape] = useState<{ path: string; w: number; h: number }>(() => { - const cfg = { - type: 'type2' as const, - strokeWidth, - scaleFactor: scale, - bBoxLabel, - bBoxArgs: bboxArgs, - }; - const brickData = generateBrickData(cfg); - return { - path: brickData.path, - w: brickData.boundingBox.w, - h: brickData.boundingBox.h, - }; + const getBrickConfig = (bBoxLabel: { w: number; h: number }) => ({ + type: 'type2' as const, + strokeWidth, + scaleFactor: scale, + bBoxLabel, + bBoxArgs: bboxArgs, }); - useEffect(() => { - const cfg = { - type: 'type2' as const, - strokeWidth, - scaleFactor: scale, - bBoxLabel, - bBoxArgs: bboxArgs, - }; - const brickData = generateBrickData(cfg); - if (RenderMetrics) { - RenderMetrics(brickData.boundingBox, brickData.connectionPoints); - } - setShape({ path: brickData.path, w: brickData.boundingBox.w, h: brickData.boundingBox.h }); - }, [label, strokeWidth, scale, bboxArgs, bBoxLabel]); - - if (!isVisible) return null; - - const svgWidth = shape.w + PADDING.left + PADDING.right; - const svgHeight = shape.h + PADDING.top + PADDING.bottom; - return ( - - - {/* Brick background outline */} - - - {/* Text label */} - {labelType === 'text' && ( - - {label} - - )} - - - {/* Accessibility tooltip */} - {tooltip && {tooltip}} - + {/* Expression-specific content */} + {value !== undefined && ( + + {String(value)} + + )} + {isValueSelectOpen && ( + + )} + ); }; diff --git a/modules/masonry/src/brick/view/components/index.ts b/modules/masonry/src/brick/view/components/index.ts new file mode 100644 index 00000000..752183e7 --- /dev/null +++ b/modules/masonry/src/brick/view/components/index.ts @@ -0,0 +1,4 @@ +export { BrickWrapper } from './BrickWrapper'; +export { SimpleBrickView } from './simple'; +export { ExpressionBrickView } from './expression'; +export { CompoundBrickView } from './compound'; diff --git a/modules/masonry/src/brick/view/components/simple.tsx b/modules/masonry/src/brick/view/components/simple.tsx index 330733fe..1ecbd95b 100644 --- a/modules/masonry/src/brick/view/components/simple.tsx +++ b/modules/masonry/src/brick/view/components/simple.tsx @@ -1,146 +1,32 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React from 'react'; import type { TBrickRenderPropsSimple } from '../../@types/brick'; -import { generateBrickData } from '../../utils/path'; - -const FONT_HEIGHT = 16; - -const PADDING = { - top: 4, - right: 10, - bottom: 4, - left: 8, -}; - -function toCssColor(color: string | ['rgb' | 'hsl', number, number, number]) { - if (typeof color === 'string') return color; - const [mode, a, b, c] = color; - return mode === 'rgb' ? `rgb(${a},${b},${c})` : `hsl(${a},${b}%,${c}%)`; -} - -function measureLabel(label: string, fontSize: number) { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - ctx.font = `${fontSize}px sans-serif`; - const m = ctx.measureText(label); - const ascent = m.actualBoundingBoxAscent ?? fontSize * 0.8; - const descent = m.actualBoundingBoxDescent ?? fontSize * 0.2; - const height = ascent + descent; - - return { - w: m.width + 8, - h: height, - ascent, - descent, - }; -} - -type TConnectionPoints = { - top?: { x: number; y: number }; - right: { x: number; y: number }[]; - bottom?: { x: number; y: number }; - left?: { x: number; y: number }; -}; +import { BrickWrapper } from './BrickWrapper'; +import type { TConnectionPoints } from '../utils/common'; type PropsWithMetrics = TBrickRenderPropsSimple & { RenderMetrics?: (bbox: { w: number; h: number }, connectionPoints: TConnectionPoints) => void; }; export const SimpleBrickView: React.FC = (props) => { - const { - label, - labelType, - colorBg, - colorFg, - strokeColor, - strokeWidth, - scale, - shadow, - tooltip, - bboxArgs, - visualState, - isActionMenuOpen, - isVisible, - topNotch, - bottomNotch, - RenderMetrics, - } = props; + const { topNotch, bottomNotch, strokeWidth, scale, bboxArgs, ...commonProps } = props; - // Memoize bBoxLabel to prevent unnecessary recalculations - const bBoxLabel = useMemo(() => { - const { w: labelW, h: labelH } = measureLabel(label, FONT_HEIGHT); - return { w: labelW, h: labelH }; - }, [label]); - - const [shape, setShape] = useState<{ path: string; w: number; h: number }>(() => { - const cfg = { - type: 'type1' as const, - strokeWidth, - scaleFactor: scale, - bBoxLabel, - bBoxArgs: bboxArgs, - hasNotchAbove: topNotch, - hasNotchBelow: bottomNotch, - }; - const brickData = generateBrickData(cfg); - return { - path: brickData.path, - w: brickData.boundingBox.w, - h: brickData.boundingBox.h, - }; + const getBrickConfig = (bBoxLabel: { w: number; h: number }) => ({ + type: 'type1' as const, + strokeWidth, + scaleFactor: scale, + bBoxLabel, + bBoxArgs: bboxArgs, + hasNotchAbove: topNotch, + hasNotchBelow: bottomNotch, }); - useEffect(() => { - const cfg = { - type: 'type1' as const, - strokeWidth, - scaleFactor: scale, - bBoxLabel, - bBoxArgs: bboxArgs, - hasNotchAbove: topNotch, - hasNotchBelow: bottomNotch, - }; - const brickData = generateBrickData(cfg); - if (RenderMetrics) { - RenderMetrics(brickData.boundingBox, brickData.connectionPoints); - } - setShape({ path: brickData.path, w: brickData.boundingBox.w, h: brickData.boundingBox.h }); - }, [label, strokeWidth, scale, bboxArgs, topNotch, bottomNotch, bBoxLabel]); - - if (!isVisible) return null; - - const svgWidth = shape.w + PADDING.left + PADDING.right; - const svgHeight = shape.h + PADDING.top + PADDING.bottom; - return ( - - - - {labelType === 'text' && ( - - {label} - - )} - - {tooltip && {tooltip}} - + ); }; diff --git a/modules/masonry/src/brick/view/utils/common.ts b/modules/masonry/src/brick/view/utils/common.ts new file mode 100644 index 00000000..06f3e76d --- /dev/null +++ b/modules/masonry/src/brick/view/utils/common.ts @@ -0,0 +1,33 @@ +/** + * Common utilities for brick view components + */ + +import type { TColor } from '../../@types/brick'; + +export const FONT_HEIGHT = 16; + +export const PADDING = { + top: 4, + right: 8, + bottom: 4, + left: 8, +}; + +/** + * Convert color format to CSS color string + */ +export function toCssColor(color: TColor) { + if (typeof color === 'string') return color; + const [mode, a, b, c] = color; + return mode === 'rgb' ? `rgb(${a},${b},${c})` : `hsl(${a},${b}%,${c}%)`; +} + +/** + * Connection points type definition + */ +export type TConnectionPoints = { + top?: { x: number; y: number }; + right: { x: number; y: number }[]; + bottom?: { x: number; y: number }; + left?: { x: number; y: number }; +}; diff --git a/modules/masonry/src/tower/model/model.ts b/modules/masonry/src/tower/model/model.ts new file mode 100644 index 00000000..6c97e2c5 --- /dev/null +++ b/modules/masonry/src/tower/model/model.ts @@ -0,0 +1,231 @@ +import type { IBrick } from '../../@types/brick'; +import type { + TPoint, + TNotchType, + TBrickConnection, + TConnectionValidation, +} from '../../@types/tower'; + +/** + * Public representation of a node inside a tower. + */ +export interface ITowerNode { + brick: IBrick; + position: TPoint; + parent: ITowerNode | null; + connectedNotches: Set; + isNested?: boolean; + argIndex?: number; +} + +/** + * A Tower represents one connected graph / stack of bricks. + */ +export default class TowerModel { + readonly id: string; + + // Internal graph data + private readonly nodes = new Map(); + private connections: TBrickConnection[] = []; + + constructor(id: string, rootBrick: IBrick, position: TPoint) { + this.id = id; + const rootNode: ITowerNode = { + brick: rootBrick, + position, + parent: null, + connectedNotches: new Set(), + }; + this.nodes.set(rootBrick.uuid, rootNode); + } + + /** All bricks currently in this tower */ + get bricks(): IBrick[] { + return Array.from(this.nodes.values()).map((n) => n.brick); + } + + /** All physical connections inside this tower */ + get allConnections(): readonly TBrickConnection[] { + return this.connections; + } + + hasBrick(brickId: string): boolean { + return this.nodes.has(brickId); + } + + /** Direct accessors guarded by readonly wrappers */ + getNode(brickId: string): ITowerNode | undefined { + return this.nodes.get(brickId); + } + + nodesArray(): ITowerNode[] { + return Array.from(this.nodes.values()); + } + + /** Position helpers */ + getBrickPosition(brickId: string): TPoint | undefined { + return this.nodes.get(brickId)?.position; + } + setBrickPosition(brickId: string, pos: TPoint): void { + const node = this.nodes.get(brickId); + if (node) node.position = pos; + } + + /** + * Add a child brick under an existing parent brick inside this tower. + */ + addBrick(parentId: string, brick: IBrick, position: TPoint): void { + const parentNode = this.nodes.get(parentId); + if (!parentNode) throw new Error('Parent brick not found in tower'); + + const node: ITowerNode = { + brick, + position, + parent: parentNode, + connectedNotches: new Set(), + }; + this.nodes.set(brick.uuid, node); + } + + /** + * Add an argument brick to a parent brick at a specific argument slot. + */ + addArgumentBrick(parentId: string, brick: IBrick, position: TPoint, argIndex?: number): void { + const parentNode = this.nodes.get(parentId); + if (!parentNode) throw new Error('Parent brick not found in tower'); + + const node: ITowerNode = { + brick, + position, + parent: parentNode, + connectedNotches: new Set(), + argIndex, + }; + this.nodes.set(brick.uuid, node); + } + + /** + * Add a nested brick inside a compound brick. + */ + addNestedBrick(parentId: string, brick: IBrick, position: TPoint): void { + const parentNode = this.nodes.get(parentId); + if (!parentNode) throw new Error('Parent brick not found in tower'); + + const node: ITowerNode = { + brick, + position, + parent: parentNode, + connectedNotches: new Set(), + isNested: true, + }; + this.nodes.set(brick.uuid, node); + } + + /** + * Connect two bricks inside this tower. + */ + connectBricks( + fromBrickId: string, + toBrickId: string, + fromNotchId: string, + toNotchId: string, + type: TNotchType, + ): TConnectionValidation { + const fromNode = this.nodes.get(fromBrickId); + const toNode = this.nodes.get(toBrickId); + if (!fromNode || !toNode) return { isValid: false, reason: 'Brick(s) not in this tower' }; + + if (fromNode.connectedNotches.has(fromNotchId) || toNode.connectedNotches.has(toNotchId)) { + return { isValid: false, reason: 'One or both notches already connected' }; + } + + fromNode.connectedNotches.add(fromNotchId); + toNode.connectedNotches.add(toNotchId); + + this.connections.push({ + from: fromBrickId, + to: toBrickId, + fromNotchId, + toNotchId, + type, + }); + + if (type === 'top-bottom' || type === 'right-left') { + toNode.parent = fromNode; + } else if (type === 'left-right') { + fromNode.parent = toNode; + } + return { isValid: true }; + } + + /** + * Merge all nodes & connections from `other` into this tower. + * Duplicates (by brick uuid) are ignored. + */ + mergeIn(other: TowerModel): void { + other.nodes.forEach((node, id) => { + if (!this.nodes.has(id)) { + this.nodes.set(id, node); + } + }); + + // Avoid pushing duplicate connections + const existing = new Set(this.connections.map((c) => JSON.stringify(c))); + other.allConnections.forEach((c) => { + const key = JSON.stringify(c); + if (!existing.has(key)) this.connections.push(c); + }); + } + + /** + * Detach a subtree starting from `brickId`, returning a **new** `TowerModel`. + */ + detachSubTree( + brickId: string, + newTowerId: string, + ): { + detachedTower: TowerModel; + removedConnections: TBrickConnection[]; + } { + const startNode = this.nodes.get(brickId); + if (!startNode) throw new Error('Brick not found in tower'); + + // collect all nodes in the subtree (DFS) + const nodesToMove = new Map(); + const stack: ITowerNode[] = [startNode]; + while (stack.length) { + const n = stack.pop()!; + nodesToMove.set(n.brick.uuid, n); + this.nodes.forEach((child) => { + if (child.parent?.brick.uuid === n.brick.uuid) stack.push(child); + }); + } + + // create the new tower + const rootPos = { ...startNode.position }; + const detached = new TowerModel(newTowerId, startNode.brick, rootPos); + + nodesToMove.forEach((node, id) => { + if (id === brickId) return; // root already exists in detached + detached.nodes.set(id, node); + }); + + // move / prune connections + const removedConnections: TBrickConnection[] = []; + this.connections = this.connections.filter((conn) => { + const inSubtree = nodesToMove.has(conn.from) && nodesToMove.has(conn.to); + if (inSubtree) { + detached.connections.push(conn); + return false; // remove from original tower + } + const touchesSubtree = nodesToMove.has(conn.from) || nodesToMove.has(conn.to); + if (touchesSubtree) removedConnections.push(conn); + return !touchesSubtree; + }); + + // finally delete nodes from original tower + nodesToMove.forEach((_, id) => this.nodes.delete(id)); + + return { detachedTower: detached, removedConnections }; + } +} diff --git a/modules/masonry/src/tower/view/components/TowerView.tsx b/modules/masonry/src/tower/view/components/TowerView.tsx new file mode 100644 index 00000000..2d67ae97 --- /dev/null +++ b/modules/masonry/src/tower/view/components/TowerView.tsx @@ -0,0 +1,285 @@ +import React, { useMemo } from 'react'; +import type { JSX } from 'react'; +import type TowerModel from '../../model/model'; +import type { ITowerNode } from '../../model/model'; +import { SimpleBrickView } from '../../../brick/view/components/simple'; +import { ExpressionBrickView } from '../../../brick/view/components/expression'; +import { CompoundBrickView } from '../../../brick/view/components/compound'; +import CompoundBrick from '../../../brick/model/model'; +import type { BrickModel, SimpleBrick, ExpressionBrick } from '../../../brick/model/model'; + +// Extended ITowerNode to ensure compatibility +interface ExtendedTowerNode extends ITowerNode { + isNested?: boolean; + argIndex?: number; +} + +// Helper to render the correct brick view +function BrickNodeView({ node }: { node: ExtendedTowerNode }) { + const { brick } = node; + switch (brick.type) { + case 'Simple': + return ; + case 'Expression': + return ; + case 'Compound': + return ; + default: + return null; + } +} + +// Helper function to get children of a node from the tower structure +function getNodeChildren( + nodeId: string, + allNodes: Map, +): { + nested: ExtendedTowerNode[]; + args: ExtendedTowerNode[]; + stacked: ExtendedTowerNode[]; +} { + const nested: ExtendedTowerNode[] = []; + const args: ExtendedTowerNode[] = []; + const stacked: ExtendedTowerNode[] = []; + + for (const [_, node] of allNodes) { + if (node.parent?.brick.uuid === nodeId) { + if (node.isNested) { + nested.push(node); + } else if (node.argIndex !== undefined) { + args.push(node); + } else { + stacked.push(node); + } + } + } + + // Sort args by index + args.sort((a, b) => (a.argIndex || 0) - (b.argIndex || 0)); + + return { nested, args, stacked }; +} + +// Compute bounding boxes for all nodes (bottom-up) +function computeBoundingBoxes( + allNodes: Map, +): Map { + const bbMap = new Map(); + const visited = new Set(); + + // Post-order traversal: children first + const visit = (node: ExtendedTowerNode) => { + if (visited.has(node.brick.uuid)) { + return bbMap.get(node.brick.uuid)!; + } + visited.add(node.brick.uuid); + + const children = getNodeChildren(node.brick.uuid, allNodes); + + // Start with the brick's own bounding box + let width = node.brick.boundingBox.w; + let height = node.brick.boundingBox.h; + + // Handle nested children (for compound bricks) + if (children.nested.length > 0) { + let nestedHeight = 0; + let nestedWidth = 0; + + children.nested.forEach((child) => { + const _childBB = visit(child); + nestedHeight += _childBB.h; + nestedWidth = Math.max(nestedWidth, _childBB.w); + }); + + // Update the compound brick's layout with nested children + if (node.brick instanceof CompoundBrick) { + const nestedBricks = children.nested.map((child) => child.brick as BrickModel); + node.brick.updateLayoutWithChildren(nestedBricks); + + // Recalculate the brick's bounding box after layout update + width = node.brick.boundingBox.w; + height = node.brick.boundingBox.h; + } else { + // For non-compound bricks, expand to fit nested content + width = Math.max(width, nestedWidth + 20); // Add some padding + height = Math.max(height, node.brick.boundingBox.h + nestedHeight); + } + } + + // Handle argument children - they don't affect parent size as they're positioned at specific slots + children.args.forEach((child) => { + visit(child); // Just ensure they're processed + }); + + // Handle stacked children (vertical stack below this brick) + if (children.stacked.length > 0) { + let stackedHeight = 0; + let stackedWidth = 0; + + children.stacked.forEach((child) => { + const childBB = visit(child); + stackedHeight += childBB.h; + stackedWidth = Math.max(stackedWidth, childBB.w); + }); + + // Stacked children extend the total height and may affect width + width = Math.max(width, stackedWidth); + height += stackedHeight; + } + + const result = { w: width, h: height }; + bbMap.set(node.brick.uuid, result); + return result; + }; + + // Find roots and process them + const roots = Array.from(allNodes.values()).filter((n) => n.parent === null); + roots.forEach(visit); + + return bbMap; +} + +// Render tower using iterative approach with correct positioning +function RenderTowerNodeStack({ + node, + allNodes, + bbMap, + offset = { x: 0, y: 0 }, +}: { + node: ExtendedTowerNode; + allNodes: Map; + bbMap: Map; + offset?: { x: number; y: number }; +}) { + const elements: JSX.Element[] = []; + const stack: Array<{ node: ExtendedTowerNode; x: number; y: number }> = [ + { node, x: offset.x, y: offset.y }, + ]; + + while (stack.length > 0) { + const { node: curr, x, y } = stack.pop()!; + const children = getNodeChildren(curr.brick.uuid, allNodes); + + // Render the current brick + elements.push( + + + , + ); + + // Handle nested children - positioned inside the current brick + if (children.nested.length > 0 && curr.brick.connectionPoints.nested) { + let nestedOffsetY = 0; + + children.nested.forEach((child) => { + const nestedX = x + curr.brick.connectionPoints.nested!.x; + const nestedY = y + curr.brick.connectionPoints.nested!.y + nestedOffsetY; + + stack.push({ + node: child, + x: nestedX, + y: nestedY, + }); + + const _childBB = bbMap.get(child.brick.uuid)!; + nestedOffsetY += _childBB.h; + }); + } + + // Handle argument children - positioned at specific argument slots + if (children.args.length > 0 && curr.brick.connectionPoints.args) { + children.args.forEach((child) => { + const argIndex = child.argIndex || 0; + if (argIndex < curr.brick.connectionPoints.args!.length) { + const argOrigin = curr.brick.connectionPoints.args![argIndex]; + + stack.push({ + node: child, + x: x + argOrigin.x, + y: y + argOrigin.y, + }); + } + }); + } + + // Handle stacked children - positioned below the current brick + if (children.stacked.length > 0) { + let stackedOffsetY = y + curr.brick.boundingBox.h; + + children.stacked.forEach((child) => { + stack.push({ + node: child, + x: x, + y: stackedOffsetY, + }); + + const _childBB3 = bbMap.get(child.brick.uuid)!; + stackedOffsetY += _childBB3.h; + }); + } + } + + return <>{elements}; +} + +// Helper function to recursively update layout for compound bricks +function updateCompoundBrickLayouts( + nodes: Map, + node?: ExtendedTowerNode, +): void { + // If node is not provided, start from all root nodes + if (!node) { + for (const n of nodes.values()) { + if (n.parent === null) updateCompoundBrickLayouts(nodes, n); + } + return; + } + + if (node.brick instanceof CompoundBrick) { + const children = getNodeChildren(node.brick.uuid, nodes); + if (children.nested.length > 0) { + const nestedBricks = children.nested.map((child) => child.brick as BrickModel); + node.brick.updateLayoutWithChildren(nestedBricks); + // Recursively update only the nested children + children.nested.forEach((child) => { + updateCompoundBrickLayouts(nodes, child); + }); + } + } +} + +// Main TowerView component +const TowerView: React.FC<{ tower: TowerModel }> = ({ tower }) => { + const renderedTower = useMemo(() => { + // Cast to extended type + const extendedNodes = new Map(); + const nodes = tower.nodesArray(); + + for (const node of nodes) { + extendedNodes.set(node.brick.uuid, node as ExtendedTowerNode); + } + + // Update compound brick layouts first + updateCompoundBrickLayouts(extendedNodes); + + // Compute bounding boxes + const bbMap = computeBoundingBoxes(extendedNodes); + + // Find root nodes + const roots = Array.from(extendedNodes.values()).filter((n) => n.parent === null); + + return roots.map((root) => ( + + )); + }, [tower]); + + return {renderedTower}; +}; + +export default TowerView; diff --git a/modules/masonry/src/tower/view/stories/InteractiveTower.stories.tsx b/modules/masonry/src/tower/view/stories/InteractiveTower.stories.tsx new file mode 100644 index 00000000..c73b3522 --- /dev/null +++ b/modules/masonry/src/tower/view/stories/InteractiveTower.stories.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import TowerView from '../components/TowerView'; +import TowerModel from '../../model/model'; + +import { + createSimpleBrick, + createExpressionBrick, + createCompoundBrick, + resetFactoryCounter, +} from '../../../brick/utils/brickFactory'; + +type RootType = 'Simple' | 'Compound'; +type BrickType = 'Simple' | 'Compound'; + +type FullControlProps = { + rootType: RootType; + numArgs: number; + numNested: number; + nestingDepth: number; + stackCount: number; + nestedBrickTypes: BrickType[]; +}; + +const FullControlTower: React.FC = ({ + rootType, + numArgs, + numNested, + nestingDepth, + stackCount, + nestedBrickTypes, +}) => { + resetFactoryCounter(); + + const bboxArgs = Array(numArgs).fill({ w: 60, h: 20 }); + + // Root Brick + const root = + rootType === 'Simple' + ? createSimpleBrick({ label: 'Root Simple', bboxArgs }) + : createCompoundBrick({ label: 'Root Compound', bboxArgs }); + + const tower = new TowerModel('interactive_tower', root, { x: 100, y: 100 }); + + // Add argument bricks (only expressions) + for (let i = 0; i < numArgs; i++) { + const expr = createExpressionBrick({ label: `Expr ${i + 1}` }); + tower.addArgumentBrick(root.uuid, expr, { x: 0, y: 0 }, i); + } + + // Recursive compound brick builder with insertion + const buildNestedCompound = (parentUuid: string, depth: number, label: string) => { + const compound = createCompoundBrick({ label }); + + // Add to parent first + tower.addNestedBrick(parentUuid, compound, { x: 0, y: 0 }); + + // Recursively nest further if needed + if (depth > 1) { + buildNestedCompound(compound.uuid, depth - 1, `${label}-Child`); + } + }; + + // Add nested bricks based on types + for (let i = 0; i < numNested; i++) { + const type = nestedBrickTypes[i] || 'Simple'; + + if (type === 'Compound') { + buildNestedCompound(root.uuid, nestingDepth, `Compound ${i + 1}`); + } else { + const simple = createSimpleBrick({ label: `Simple ${i + 1}` }); + tower.addNestedBrick(root.uuid, simple, { x: 0, y: 0 }); + } + } + + // Add stacked bricks + let lastUuid = root.uuid; + for (let i = 0; i < stackCount; i++) { + const stacked = createSimpleBrick({ label: `Stack ${i + 1}` }); + tower.addBrick(lastUuid, stacked, { + x: 100, + y: 150 + i * 30, + }); + lastUuid = stacked.uuid; + } + + return ( + + + + ); +}; + +const meta: Meta = { + title: 'Tower/Interactive Full Control', + component: FullControlTower, + argTypes: { + rootType: { + control: 'radio', + options: ['Simple', 'Compound'], + }, + numArgs: { + control: { type: 'number', min: 0, max: 5 }, + }, + numNested: { + control: { type: 'number', min: 0, max: 5 }, + }, + nestingDepth: { + control: { type: 'number', min: 0, max: 5 }, + }, + stackCount: { + control: { type: 'number', min: 0, max: 5 }, + }, + nestedBrickTypes: { + control: 'object', + description: 'Array of "Simple" or "Compound" for each nested brick', + }, + }, + args: { + rootType: 'Compound', + numArgs: 2, + numNested: 2, + nestingDepth: 2, + stackCount: 1, + nestedBrickTypes: ['Compound', 'Simple'], + }, +}; + +export default meta; +type Story = StoryObj; + +export const InteractivePlayground: Story = {}; + +export const TestArgumentPositioning: Story = { + args: { + rootType: 'Compound', + numArgs: 3, + numNested: 0, + nestingDepth: 1, + stackCount: 0, + nestedBrickTypes: [], + }, + render: (_args) => { + resetFactoryCounter(); + + // Test with different label lengths + const shortLabel = createCompoundBrick({ + label: 'Short', + bboxArgs: Array(3).fill({ w: 60, h: 20 }), + }); + const longLabel = createCompoundBrick({ + label: 'Very Long Label That Should Push Args Further Right', + bboxArgs: Array(3).fill({ w: 60, h: 20 }), + }); + + const tower1 = new TowerModel('tower1', shortLabel, { x: 100, y: 50 }); + const tower2 = new TowerModel('tower2', longLabel, { x: 100, y: 200 }); + + // Add argument bricks to both + for (let i = 0; i < 3; i++) { + const expr1 = createExpressionBrick({ label: `Arg ${i + 1}` }); + const expr2 = createExpressionBrick({ label: `Arg ${i + 1}` }); + tower1.addArgumentBrick(shortLabel.uuid, expr1, { x: 0, y: 0 }, i); + tower2.addArgumentBrick(longLabel.uuid, expr2, { x: 0, y: 0 }, i); + } + + return ( + + + + + ); + }, +}; diff --git a/modules/masonry/src/tower/view/stories/TowerView.stories.tsx b/modules/masonry/src/tower/view/stories/TowerView.stories.tsx new file mode 100644 index 00000000..25b3aa5a --- /dev/null +++ b/modules/masonry/src/tower/view/stories/TowerView.stories.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import TowerView from '../components/TowerView'; +import TowerModel from '../../model/model'; +import { + createSimpleBrick, + createExpressionBrick, + createCompoundBrick, + resetFactoryCounter, +} from '../../../brick/utils/brickFactory'; + +const meta: Meta = { + title: 'Tower/Different-layouts', + component: TowerView, +}; +export default meta; +type Story = StoryObj; + +// === Story 1: Single Simple Brick (from tree SingleBrick) === +export const SingleBrick: Story = { + render: () => { + resetFactoryCounter(); + const brick = createSimpleBrick(); + const tower = new TowerModel('tower_single', brick, { x: 100, y: 100 }); + + return ( + + + + ); + }, +}; + +// === Story 2: Stacked Bricks (from tree StackedBricks) === +export const StackedBricks: Story = { + render: () => { + resetFactoryCounter(); + const root = createSimpleBrick(); + const child1 = createSimpleBrick(); + const child2 = createSimpleBrick(); + + const tower = new TowerModel('tower_stack', root, { x: 100, y: 100 }); + tower.addBrick(root.uuid, child1, { x: 100, y: 130 }); + tower.addBrick(child1.uuid, child2, { x: 100, y: 160 }); + + return ( + + + + ); + }, +}; + +// === Story 3: Brick with Arguments (from tree ArgumentBricks) === +export const ArgumentBricks: Story = { + render: () => { + resetFactoryCounter(); + const root = createSimpleBrick({ + label: 'My Simple', + bboxArgs: [ + { w: 60, h: 40 }, + { w: 60, h: 20 }, + ], + }); + + const arg1 = createExpressionBrick({ label: 'Expr A', bboxArgs: [{ w: 60, h: 40 }] }); + const arg2 = createExpressionBrick({ label: 'Expr B', bboxArgs: [{ w: 60, h: 20 }] }); + + const tower = new TowerModel('tower_args', root, { x: 100, y: 100 }); + tower.addArgumentBrick(root.uuid, arg1, { x: 0, y: 0 }, 0); + tower.addArgumentBrick(root.uuid, arg2, { x: 0, y: 0 }, 1); + + return ( + + + + ); + }, +}; + +// === Story 4: Compound with Nested Children (from tree CompoundWithNested) === +export const CompoundWithNested: Story = { + render: () => { + resetFactoryCounter(); + const compound = createCompoundBrick({ + label: 'Compound with Nested', + bboxArgs: [{ w: 80, h: 40 }], + }); + + const nested1 = createSimpleBrick(); + const nested2 = createSimpleBrick(); + + const tower = new TowerModel('tower_compound', compound, { x: 200, y: 100 }); + tower.addNestedBrick(compound.uuid, nested1, { x: 0, y: 0 }); + tower.addNestedBrick(compound.uuid, nested2, { x: 0, y: 0 }); + + return ( + + + + ); + }, +}; + +// === Story 5: Full Composite Tower (from tree FullCompositeTree) === +export const FullCompositeTower: Story = { + render: () => { + resetFactoryCounter(); + const compound = createCompoundBrick({ + label: 'Full Composite box with args', + bboxArgs: [ + { w: 100, h: 50 }, + { w: 120, h: 70 }, + ], + }); + + const arg1 = createExpressionBrick({ label: 'Expr 1', bboxArgs: [{ w: 100, h: 50 }] }); + const arg2 = createExpressionBrick({ label: 'Expr 2', bboxArgs: [{ w: 120, h: 70 }] }); + const nested = createSimpleBrick(); + const stacked = createSimpleBrick(); + + const tower = new TowerModel('tower_full', compound, { x: 100, y: 100 }); + tower.addArgumentBrick(compound.uuid, arg1, { x: 0, y: 0 }, 0); + tower.addArgumentBrick(compound.uuid, arg2, { x: 0, y: 0 }, 1); + tower.addNestedBrick(compound.uuid, nested, { x: 0, y: 0 }); + tower.addBrick(compound.uuid, stacked, { x: 100, y: 160 }); + + return ( + + + + ); + }, +}; diff --git a/modules/masonry/src/tree/model/model.ts b/modules/masonry/src/tree/model/model.ts deleted file mode 100644 index fceafa3f..00000000 --- a/modules/masonry/src/tree/model/model.ts +++ /dev/null @@ -1,485 +0,0 @@ -import type { IBrick } from '../../brick/@types/brick'; - -// Point type -export type TPoint = { - x: number; - y: number; -}; - -// Notch type -export type TNotchType = 'top-bottom' | 'right-left' | 'left-right'; - -// Connection point types -export type TConnectionPoint = { - x: number; - y: number; -}; - -export type TConnectionPoints = { - top?: TConnectionPoint; - right: TConnectionPoint[]; - bottom?: TConnectionPoint; - left?: TConnectionPoint; - nested?: TConnectionPoint; -}; - -// Connection types between bricks -export type TConnectionType = 'top-bottom' | 'left-right' | 'right-left' | 'nested'; - -// Connection between two bricks -export type TBrickConnection = { - from: string; - to: string; - fromNotchId: string; - toNotchId: string; - type: TNotchType; -}; - -// Tree node representing a brick in the tree -export type TTreeNode = { - brick: IBrick; - position: TPoint; - parent: TTreeNode | null; - connectedNotches: Set; -}; - -// Tree structure representing connected bricks -export type TTree = { - id: string; - nodes: Map; - connections: TBrickConnection[]; -}; - -// Connection validation result -export type TConnectionValidation = { - isValid: boolean; - reason?: string; -}; - -// Main tree manager class -export default class BrickTreeManager { - private trees: TTree[] = []; - private nextTreeId = 1; - - constructor() {} - - /** - * Finds the ID of a notch on a brick based on connection point coordinates. - * @param brick The brick to search on. - * @param point The connection point coordinates. - * @returns The notch ID (e.g., 'left', 'right_0') or null if not found. - */ - private findNotchId(brick: IBrick, point: TConnectionPoint): string | null { - const { connectionPoints } = brick; - const tolerance = 1.5; // Use a slightly larger tolerance - - if ( - connectionPoints.top && - Math.hypot(connectionPoints.top.x - point.x, connectionPoints.top.y - point.y) < - tolerance - ) { - return 'top'; - } - if ( - connectionPoints.bottom && - Math.hypot(connectionPoints.bottom.x - point.x, connectionPoints.bottom.y - point.y) < - tolerance - ) { - return 'bottom'; - } - if ( - connectionPoints.left && - Math.hypot(connectionPoints.left.x - point.x, connectionPoints.left.y - point.y) < - tolerance - ) { - return 'left'; - } - for (let i = 0; i < connectionPoints.right.length; i++) { - const rightPoint = connectionPoints.right[i]; - if (Math.hypot(rightPoint.x - point.x, rightPoint.y - point.y) < tolerance) { - return `right_${i}`; - } - } - return null; - } - - /** - * Creates a new tree with a single brick - */ - public createTree(brick: IBrick, position: TPoint): TTree { - const treeId = `tree_${this.nextTreeId++}`; - const node: TTreeNode = { - brick, - position, - parent: null, - connectedNotches: new Set(), - }; - const tree: TTree = { - id: treeId, - nodes: new Map([[brick.uuid, node]]), - connections: [], - }; - this.trees.push(tree); - return tree; - } - - /** - * Adds a brick to an existing tree - */ - public addBrickToTree(treeId: string, brick: IBrick, parentBrickId: string, position: TPoint) { - const tree = this.trees.find((t) => t.id === treeId); - if (!tree) return; - - const parentNode = tree.nodes.get(parentBrickId); - if (!parentNode) return; - - const node: TTreeNode = { - brick, - position, - parent: parentNode, - connectedNotches: new Set(), - }; - tree.nodes.set(brick.uuid, node); - } - - /** - * Connects two trees or bricks with validation - */ - public connectBricks( - fromBrickId: string, - toBrickId: string, - fromPoint: TConnectionPoint, - toPoint: TConnectionPoint, - connectionType: TNotchType, - ): string | null { - const fromBrickNode = this.getBrickNode(fromBrickId); - const toBrickNode = this.getBrickNode(toBrickId); - - if (!fromBrickNode || !toBrickNode) return null; - - // Validate that both bricks exist and can be connected - // The connection points represent the specific notches where bricks will connect - - const fromNotchId = this.findNotchId(fromBrickNode.brick, fromPoint); - const toNotchId = this.findNotchId(toBrickNode.brick, toPoint); - - // Find the specific notch IDs for both bricks based on their connection points - // These IDs are used to track which notches are occupied - - if (!fromNotchId || !toNotchId) { - console.error('Could not determine notch IDs for connection'); - return null; - } - - // Check if the notches are already connected to other bricks - // A notch can only be connected to one other notch at a time - - if ( - fromBrickNode.connectedNotches.has(fromNotchId) || - toBrickNode.connectedNotches.has(toNotchId) - ) { - console.error('One or both notches are already connected'); - return null; - } - - // Mark both notches as connected to prevent future connections - fromBrickNode.connectedNotches.add(fromNotchId); - toBrickNode.connectedNotches.add(toNotchId); - - const fromTree = this.findTreeByBrickId(fromBrickId); - const toTree = this.findTreeByBrickId(toBrickId); - - if (!fromTree || !toTree) return null; - - const connection: TBrickConnection = { - from: fromBrickId, - to: toBrickId, - fromNotchId, - toNotchId, - type: connectionType, - }; - - if (fromTree.id === toTree.id) { - // Both bricks are already in the same tree, just add the new connection - fromTree.connections.push(connection); - this.updateParentChildRelationships(fromBrickId, toBrickId, connection.type); - return fromTree.id; - } - - // Bricks are in different trees, merge them into a single tree - const mergedTree = this.mergeTrees(fromTree, toTree, connection); - this.updateParentChildRelationships(fromBrickId, toBrickId, connection.type); - return mergedTree.id; - } - - private mergeTrees(tree1: TTree, tree2: TTree, connection: TBrickConnection): TTree { - tree2.nodes.forEach((node, brickId) => { - tree1.nodes.set(brickId, node); - }); - tree2.connections.forEach((conn) => { - tree1.connections.push(conn); - }); - tree1.connections.push(connection); - this.trees = this.trees.filter((t) => t.id !== tree2.id); - return tree1; - } - - /** - * Updates parent-child relationships based on connection type - */ - private updateParentChildRelationships( - fromBrickId: string, - toBrickId: string, - notchType: TNotchType, - ) { - const fromNode = this.getBrickNode(fromBrickId); - const toNode = this.getBrickNode(toBrickId); - if (!fromNode || !toNode) return; - - if (notchType === 'top-bottom' || notchType === 'right-left') { - toNode.parent = fromNode; - } else if (notchType === 'left-right') { - fromNode.parent = toNode; - } - } - - /** - * Disconnects a brick from its tree - * Handles hierarchical relationships like a folder structure: - * - If disconnecting a parent, all children remain connected to it - * - If disconnecting a child, it becomes a separate tree - */ - public disconnectBrick(brickId: string): { - removedConnections: TBrickConnection[]; - newTreeIds: string[]; - } { - const brickNode = this.getBrickNode(brickId); - if (!brickNode) return { removedConnections: [], newTreeIds: [] }; - - const originalTree = this.findTreeByBrickId(brickId); - if (!originalTree) return { removedConnections: [], newTreeIds: [] }; - - // Step 1: Collect all descendant nodes that will move with the disconnected brick - // This includes the brick itself and all its children (hierarchical behavior) - const nodesToMove = new Map(); - const stack: TTreeNode[] = [brickNode]; - const visited = new Set([brickNode.brick.uuid]); - - while (stack.length > 0) { - const currentNode = stack.pop()!; - nodesToMove.set(currentNode.brick.uuid, currentNode); - - // Add all children of the current node to the stack for processing - this.getBrickChildren(currentNode.brick.uuid).forEach((child) => { - if (!visited.has(child.brick.uuid)) { - visited.add(child.brick.uuid); - stack.push(child); - } - }); - } - - // Step 2: Identify connections that need to be removed from the original tree - // Remove connections where: - // - Both nodes are moving to the new tree (internal connections) - // - One node is moving and the other stays (cross-tree connections) - const connectionsToRemove = originalTree.connections.filter((conn) => { - const fromInNewTree = nodesToMove.has(conn.from); - const toInNewTree = nodesToMove.has(conn.to); - - // Remove connections where both nodes are moving to the new tree - // OR connections where one node is moving and the other stays in original tree - const shouldRemove = - (fromInNewTree && toInNewTree) || - (fromInNewTree && !toInNewTree) || - (!fromInNewTree && toInNewTree); - - return shouldRemove; - }); - - if (connectionsToRemove.length === 0) return { removedConnections: [], newTreeIds: [] }; - - // Step 3: Remove connections and nodes from the original tree - this.removeConnections(originalTree, connectionsToRemove); - nodesToMove.forEach((node, brickId) => { - originalTree.nodes.delete(brickId); - }); - - // Step 4: Create a new tree with the disconnected brick as root - const newTree = this.createTree(brickNode.brick, brickNode.position); - - // Step 5: Add all descendant nodes to the new tree - nodesToMove.forEach((node, brickId) => { - if (brickId !== brickNode.brick.uuid) { - // Don't add the root twice - newTree.nodes.set(brickId, node); - } - }); - - // Step 6: Move internal connections to the new tree - const connectionsToMove = connectionsToRemove.filter( - (conn) => nodesToMove.has(conn.from) && nodesToMove.has(conn.to), - ); - newTree.connections = connectionsToMove; - - // Step 7: Update parent relationships for the new tree - // The disconnected brick becomes the root (no parent) - brickNode.parent = null; - - // All other nodes in the new tree should have their parent relationships preserved - // but only if their parent is also in the new tree - nodesToMove.forEach((node, brickId) => { - if (brickId !== brickNode.brick.uuid && node.parent) { - if (!nodesToMove.has(node.parent.brick.uuid)) { - // If parent is not in the new tree, this node becomes a direct child of the root - node.parent = brickNode; - } - } - }); - - // Step 8: Clean up original tree if it's empty - if (originalTree.nodes.size === 0) { - this.trees = this.trees.filter((t) => t.id !== originalTree.id); - } - - return { removedConnections: connectionsToRemove, newTreeIds: [newTree.id] }; - } - - private removeConnections(tree: TTree, connectionsToRemove: TBrickConnection[]) { - connectionsToRemove.forEach((conn) => { - const fromNode = this.getBrickNode(conn.from); - const toNode = this.getBrickNode(conn.to); - if (fromNode && conn.fromNotchId) fromNode.connectedNotches.delete(conn.fromNotchId); - if (toNode && conn.toNotchId) toNode.connectedNotches.delete(conn.toNotchId); - if (toNode && toNode.parent?.brick.uuid === conn.from) toNode.parent = null; - if (fromNode && fromNode.parent?.brick.uuid === conn.to) fromNode.parent = null; - }); - tree.connections = tree.connections.filter((c) => !connectionsToRemove.includes(c)); - } - - /** - * Gets a tree by ID - */ - public getTree(treeId: string): TTree | undefined { - return this.trees.find((t) => t.id === treeId); - } - - /** - * Gets all trees - */ - public getAllTrees(): TTree[] { - return this.trees; - } - - /** - * Gets all brick IDs in a tree - */ - public getBricksInTree(treeId: string): IBrick[] { - const tree = this.getTree(treeId); - return tree ? Array.from(tree.nodes.values()).map((n) => n.brick) : []; - } - - /** - * Checks if two bricks are connected - */ - public areBricksConnected(brickId1: string, brickId2: string): boolean { - for (const tree of this.trees) { - if ( - tree.connections.some( - (c) => - (c.from === brickId1 && c.to === brickId2) || - (c.from === brickId2 && c.to === brickId1), - ) - ) { - return true; - } - } - return false; - } - - /** - * Gets all connections for a brick - */ - public getBrickConnections(brickId: string): TBrickConnection[] { - const tree = this.getTree(brickId); - if (!tree) return []; - - return tree.connections.filter((conn) => conn.from === brickId || conn.to === brickId); - } - - /** - * Moves a brick within its tree - */ - public moveBrick(brickId: string, newPosition: { x: number; y: number }): boolean { - const tree = this.getTree(brickId); - if (!tree) return false; - - const node = tree.nodes.get(brickId); - if (!node) return false; - - node.position = newPosition; - - return true; - } - - /** - * Gets the path from root to a specific brick - */ - public getPathToBrick(brickId: string): string[] { - const tree = this.getTree(brickId); - if (!tree) return []; - - const path: string[] = []; - let currentBrickId = brickId; - - while (currentBrickId) { - path.unshift(currentBrickId); - const node = tree.nodes.get(currentBrickId); - currentBrickId = node?.parent?.brick.uuid || ''; - } - - return path; - } - - /** - * Gets all children of a brick - */ - public getBrickChildren(brickId: string): TTreeNode[] { - const children: TTreeNode[] = []; - for (const tree of this.trees) { - for (const node of tree.nodes.values()) { - if (node.parent?.brick.uuid === brickId) { - children.push(node); - } - } - } - return children; - } - - /** - * Gets the parent of a brick - */ - public getBrickParent(brickId: string): string | undefined { - const node = this.getBrickNode(brickId); - return node?.parent?.brick.uuid; - } - - /** - * Clears all trees - */ - public clear(): void { - this.trees = []; - this.nextTreeId = 1; - } - - private getBrickNode(brickId: string): TTreeNode | undefined { - for (const tree of this.trees) { - const node = tree.nodes.get(brickId); - if (node) return node; - } - return undefined; - } - - private findTreeByBrickId(brickId: string): TTree | undefined { - return this.trees.find((tree) => tree.nodes.has(brickId)); - } -} diff --git a/modules/masonry/src/tree/model/spec/model.spec.ts b/modules/masonry/src/tree/model/spec/model.spec.ts deleted file mode 100644 index db84ed97..00000000 --- a/modules/masonry/src/tree/model/spec/model.spec.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import BrickTreeManager from '../model.js'; -import { SimpleBrick, ExpressionBrick } from '../../../brick/model/model'; -import CompoundBrick from '../../../brick/model/model'; -import { IBrick } from '../../../brick/@types/brick'; - -describe('BrickTreeManager', () => { - let treeManager: BrickTreeManager; - let simpleBrick1: IBrick; - let simpleBrick2: IBrick; - let expressionBrick: IBrick; - let expressionBrick2: IBrick; - let compoundBrick: IBrick; - - beforeEach(() => { - treeManager = new BrickTreeManager(); - - simpleBrick1 = new SimpleBrick({ - uuid: 'simple1', - name: 'Simple 1', - label: 's1', - labelType: 'text', - colorBg: 'red', - colorFg: 'white', - strokeColor: 'black', - shadow: false, - scale: 1, - bboxArgs: [{ w: 10, h: 20 }], // Add bboxArgs to generate right connection points - topNotch: true, - bottomNotch: true, - }); - - // Use SimpleBrick for top-bottom tests - simpleBrick2 = new SimpleBrick({ - uuid: 'simple2', - name: 'Simple 2', - label: 's2', - labelType: 'text', - colorBg: 'green', - colorFg: 'white', - strokeColor: 'black', - shadow: false, - scale: 1, - bboxArgs: [{ w: 10, h: 20 }], - topNotch: true, - bottomNotch: true, - }); - - // Use ExpressionBrick for right-to-left tests - expressionBrick = new ExpressionBrick({ - uuid: 'expr1', - name: 'Expression 1', - label: 'e1', - labelType: 'text', - colorBg: 'blue', - colorFg: 'white', - strokeColor: 'black', - shadow: false, - scale: 1, - bboxArgs: [], // Expression bricks have a left notch by default - }); - expressionBrick2 = new ExpressionBrick({ - uuid: 'expr2', - name: 'Expression 2', - label: 'e2', - labelType: 'text', - colorBg: 'orange', - colorFg: 'white', - strokeColor: 'black', - shadow: false, - scale: 1, - bboxArgs: [], - }); - - compoundBrick = new CompoundBrick({ - uuid: 'comp1', - name: 'Compound 1', - label: 'c1', - labelType: 'text', - colorBg: 'purple', - colorFg: 'white', - strokeColor: 'black', - shadow: false, - scale: 1, - bboxArgs: [{ w: 10, h: 20 }], // Add bboxArgs to generate right connection points - bboxNest: [], - topNotch: true, - bottomNotch: true, - }); - }); - - describe('Tree Creation', () => { - it('should create a new tree with a single brick', () => { - const tree = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); - expect(tree.id).toBe('tree_1'); - }); - }); - - describe('Hierarchical Tree Behavior (Folder-like Structure)', () => { - it('should disconnect a parent and keep children', () => { - const tree = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); - treeManager.addBrickToTree(tree.id, simpleBrick2, simpleBrick1.uuid, { x: 0, y: 50 }); - treeManager.connectBricks( - simpleBrick1.uuid, - simpleBrick2.uuid, - simpleBrick1.connectionPoints.bottom!, - simpleBrick2.connectionPoints.top!, - 'top-bottom', - ); - const result = treeManager.disconnectBrick(simpleBrick2.uuid); - expect(result.newTreeIds).toHaveLength(1); - expect(result.removedConnections).toHaveLength(1); - }); - }); - - describe('Right-to-Left "Puzzle" Connections', () => { - it('should connect two bricks with a right-to-left connection', () => { - treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); - treeManager.createTree(expressionBrick, { x: 120, y: 0 }); - const newTreeId = treeManager.connectBricks( - simpleBrick1.uuid, - expressionBrick.uuid, - simpleBrick1.connectionPoints.right[0], - expressionBrick.connectionPoints.left!, - 'right-left', - ); - const tree = treeManager.getTree(newTreeId!); - expect(tree?.connections[0].fromNotchId).toBe('right_0'); - expect(tree?.connections[0].toNotchId).toBe('left'); - expect(treeManager.getBrickParent(expressionBrick.uuid)).toBe(simpleBrick1.uuid); - }); - - it('should not connect if a notch is already in use', () => { - const treeId = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }).id; - treeManager.addBrickToTree(treeId, expressionBrick, simpleBrick1.uuid, { - x: 120, - y: 0, - }); - treeManager.connectBricks( - simpleBrick1.uuid, - expressionBrick.uuid, - simpleBrick1.connectionPoints.right[0], - expressionBrick.connectionPoints.left!, - 'right-left', - ); - treeManager.createTree(expressionBrick2, { x: 120, y: 50 }); - const result = treeManager.connectBricks( - simpleBrick1.uuid, - expressionBrick2.uuid, - simpleBrick1.connectionPoints.right[0], - expressionBrick2.connectionPoints.left!, - 'right-left', - ); - expect(result).toBeNull(); - }); - - it('should disconnect a right-to-left connection', () => { - treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); - treeManager.createTree(expressionBrick, { x: 120, y: 0 }); - treeManager.connectBricks( - simpleBrick1.uuid, - expressionBrick.uuid, - simpleBrick1.connectionPoints.right[0], - expressionBrick.connectionPoints.left!, - 'right-left', - ); - const result = treeManager.disconnectBrick(expressionBrick.uuid); - expect(result.removedConnections).toHaveLength(1); - expect(result.newTreeIds).toHaveLength(1); - expect(treeManager.areBricksConnected(simpleBrick1.uuid, expressionBrick.uuid)).toBe( - false, - ); - }); - }); - - // Re-introducing Tower Model Test Cases - describe('Tower Model Test Cases', () => { - it('Test Case 1: should connect multiple bricks in a chain', () => { - const treeId = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }).id; - treeManager.addBrickToTree(treeId, simpleBrick2, simpleBrick1.uuid, { x: 0, y: 50 }); - treeManager.addBrickToTree(treeId, compoundBrick, simpleBrick2.uuid, { x: 0, y: 100 }); - - treeManager.connectBricks( - simpleBrick1.uuid, - simpleBrick2.uuid, - simpleBrick1.connectionPoints.bottom!, - simpleBrick2.connectionPoints.top!, - 'top-bottom', - ); - treeManager.connectBricks( - simpleBrick2.uuid, - compoundBrick.uuid, - simpleBrick2.connectionPoints.bottom!, - (compoundBrick as IBrick).connectionPoints.top!, - 'top-bottom', - ); - expect(treeManager.getAllTrees()).toHaveLength(1); - expect(treeManager.getBricksInTree('tree_1')).toHaveLength(3); - }); - - it('Test Case 2: should disconnect a brick from the middle of a chain', () => { - const treeId = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }).id; - treeManager.addBrickToTree(treeId, simpleBrick2, simpleBrick1.uuid, { x: 0, y: 50 }); - treeManager.addBrickToTree(treeId, compoundBrick, simpleBrick2.uuid, { x: 0, y: 100 }); - treeManager.connectBricks( - simpleBrick1.uuid, - simpleBrick2.uuid, - simpleBrick1.connectionPoints.bottom!, - simpleBrick2.connectionPoints.top!, - 'top-bottom', - ); - treeManager.connectBricks( - simpleBrick2.uuid, - compoundBrick.uuid, - simpleBrick2.connectionPoints.bottom!, - (compoundBrick as IBrick).connectionPoints.top!, - 'top-bottom', - ); - - const result = treeManager.disconnectBrick(simpleBrick2.uuid); - expect(result.removedConnections).toHaveLength(2); - expect(result.newTreeIds).toHaveLength(1); // Disconnected brick forms its own tree - }); - - it('Test Case 3: should not connect when notch is already connected', () => { - treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); - treeManager.createTree(simpleBrick2, { x: 0, y: 50 }); - treeManager.connectBricks( - simpleBrick1.uuid, - simpleBrick2.uuid, - simpleBrick1.connectionPoints.bottom!, - simpleBrick2.connectionPoints.top!, - 'top-bottom', - ); - treeManager.createTree(compoundBrick, { x: 0, y: 100 }); - const result = treeManager.connectBricks( - simpleBrick1.uuid, - compoundBrick.uuid, - simpleBrick1.connectionPoints.bottom!, - (compoundBrick as IBrick).connectionPoints.top!, - 'top-bottom', - ); - expect(result).toBeNull(); - }); - }); -}); diff --git a/modules/masonry/src/workspace/model/model.ts b/modules/masonry/src/workspace/model/model.ts new file mode 100644 index 00000000..0fc2ae0f --- /dev/null +++ b/modules/masonry/src/workspace/model/model.ts @@ -0,0 +1,89 @@ +import type { IBrick } from '../../@types/brick'; +import TowerModel from '../../tower/model/model'; +import type { TPoint, TNotchType } from '../../@types/tower'; + +/** + * The **Workspace** coordinates *multiple* towers and canvas‑level concerns + * (selection, z‑order, undo-redo, etc.). + */ +export default class WorkspaceManager { + private towers = new Map(); + private _nextId = 1; + + private genId(prefix = 'tower'): string { + return `${prefix}_${this._nextId++}`; + } + + // CRUD operations + createTower(rootBrick: IBrick, position: TPoint): TowerModel { + const id = this.genId(); + const tower = new TowerModel(id, rootBrick, position); + this.towers.set(id, tower); + return tower; + } + + removeTower(towerId: string): boolean { + return this.towers.delete(towerId); + } + + getTower(towerId: string): TowerModel | undefined { + return this.towers.get(towerId); + } + + get allTowers(): readonly TowerModel[] { + return Array.from(this.towers.values()); + } + + clear(): void { + this.towers.clear(); + this._nextId = 1; + } + + /** + * Attempt to connect bricks that *may* live in different towers. + * If valid and they belong to different towers, the towers are merged. + */ + connectBricksAcrossTowers( + fromBrickId: string, + toBrickId: string, + fromNotchId: string, + toNotchId: string, + type: TNotchType, + ): void { + const fromTower = this.findTowerByBrickId(fromBrickId); + const toTower = this.findTowerByBrickId(toBrickId); + if (!fromTower || !toTower) throw new Error('Brick(s) not found'); + + if (fromTower === toTower) { + const result = fromTower.connectBricks( + fromBrickId, + toBrickId, + fromNotchId, + toNotchId, + type, + ); + if (!result.isValid) throw new Error(result.reason); + return; + } + + const fromNode = fromTower.getNode(fromBrickId); + const toNode = toTower.getNode(toBrickId); + if (!fromNode || !toNode) throw new Error('Bricks not found in towers'); + if (fromNode.connectedNotches.has(fromNotchId) || toNode.connectedNotches.has(toNotchId)) { + throw new Error('One or both notches already connected'); + } + + fromTower.mergeIn(toTower); + this.towers.delete(toTower.id); + + const res = fromTower.connectBricks(fromBrickId, toBrickId, fromNotchId, toNotchId, type); + if (!res.isValid) throw new Error(res.reason); + } + + private findTowerByBrickId(brickId: string): TowerModel | undefined { + for (const tower of this.towers.values()) { + if (tower.hasBrick(brickId)) return tower; + } + return undefined; + } +} diff --git a/modules/masonry/src/workspace/view/components/WorkspaceView.tsx b/modules/masonry/src/workspace/view/components/WorkspaceView.tsx new file mode 100644 index 00000000..f18dff07 --- /dev/null +++ b/modules/masonry/src/workspace/view/components/WorkspaceView.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type WorkspaceManager from '../../model/model'; +import TowerView from '../../../tower/view/components/TowerView'; + +const WorkspaceView: React.FC<{ manager: WorkspaceManager }> = ({ manager }) => ( + + {manager.allTowers.map((tower) => ( + + ))} + +); +export default WorkspaceView; diff --git a/modules/masonry/src/workspace/view/stories/InteractiveWorkspace.stories.tsx b/modules/masonry/src/workspace/view/stories/InteractiveWorkspace.stories.tsx new file mode 100644 index 00000000..6bc8dc20 --- /dev/null +++ b/modules/masonry/src/workspace/view/stories/InteractiveWorkspace.stories.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import WorkspaceView from '../components/WorkspaceView'; +import WorkspaceManager from '../../model/model'; + +import { + createSimpleBrick, + createExpressionBrick, + createCompoundBrick, + resetFactoryCounter, +} from '../../../brick/utils/brickFactory'; + +type RootType = 'Simple' | 'Compound'; +type BrickType = 'Simple' | 'Compound'; + +type InteractiveWorkspaceProps = { + numTowers: number; + rootType: RootType; + numArgs: number; + numNested: number; + stackCount: number; + nestedBrickTypes: BrickType[]; +}; + +const InteractiveWorkspace: React.FC = ({ + numTowers, + rootType, + numArgs, + numNested, + stackCount, + nestedBrickTypes, +}) => { + resetFactoryCounter(); + const manager = new WorkspaceManager(); + + // Create multiple towers based on configuration + for (let towerIndex = 0; towerIndex < numTowers; towerIndex++) { + const xOffset = (towerIndex % 3) * 300 + 100; + const yOffset = Math.floor(towerIndex / 3) * 400 + 100; + + const bboxArgs = Array(numArgs).fill({ w: 60, h: 20 }); + + // Root Brick + const root = + rootType === 'Simple' + ? createSimpleBrick({ label: `Tower ${towerIndex + 1} Root`, bboxArgs }) + : createCompoundBrick({ label: `Tower ${towerIndex + 1} Root`, bboxArgs }); + + const tower = manager.createTower(root, { x: xOffset, y: yOffset }); + + // Add argument bricks (only expressions) + for (let i = 0; i < numArgs; i++) { + const expr = createExpressionBrick({ label: `Arg ${i + 1}` }); + tower.addArgumentBrick(root.uuid, expr, { x: 0, y: 0 }, i); + } + + // Add nested bricks (for compound only) + if (root.type === 'Compound') { + for (let i = 0; i < numNested; i++) { + const nestedType = nestedBrickTypes[i % nestedBrickTypes.length] || 'Simple'; + const nested = + nestedType === 'Simple' + ? createSimpleBrick({ label: `Nested ${i + 1}` }) + : createCompoundBrick({ label: `Nested ${i + 1}` }); + tower.addNestedBrick(root.uuid, nested, { x: 0, y: 0 }); + } + } + + // Add stacked bricks + let currentParent = root; + for (let i = 0; i < stackCount; i++) { + const stacked = createSimpleBrick({ label: `Stack ${i + 1}` }); + tower.addBrick(currentParent.uuid, stacked, { x: 0, y: 0 }); + currentParent = stacked; + } + } + + return ; +}; + +const meta: Meta = { + title: 'Workspace/Interactive', + component: InteractiveWorkspace, + argTypes: { + numTowers: { + control: { type: 'range', min: 1, max: 9 }, + description: 'Number of towers to create', + }, + rootType: { + control: { type: 'select' }, + options: ['Simple', 'Compound'], + description: 'Type of root brick for each tower', + }, + numArgs: { + control: { type: 'range', min: 0, max: 4 }, + description: 'Number of argument slots per root brick', + }, + numNested: { + control: { type: 'range', min: 0, max: 4 }, + description: 'Number of nested bricks (for compound only)', + }, + stackCount: { + control: { type: 'range', min: 0, max: 4 }, + description: 'Depth of stacked bricks below root', + }, + nestedBrickTypes: { + control: { type: 'check' }, + options: ['Simple', 'Compound'], + description: 'Types of nested bricks to use', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const SimpleWorkspace: Story = { + args: { + numTowers: 2, + rootType: 'Simple', + numArgs: 1, + numNested: 0, + stackCount: 2, + nestedBrickTypes: ['Simple'], + }, +}; + +export const ComplexWorkspace: Story = { + args: { + numTowers: 6, + rootType: 'Compound', + numArgs: 3, + numNested: 3, + stackCount: 2, + nestedBrickTypes: ['Simple', 'Compound'], + }, +}; diff --git a/modules/masonry/src/workspace/view/stories/WorkspaceView.stories.tsx b/modules/masonry/src/workspace/view/stories/WorkspaceView.stories.tsx new file mode 100644 index 00000000..a57008b6 --- /dev/null +++ b/modules/masonry/src/workspace/view/stories/WorkspaceView.stories.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import WorkspaceView from '../components/WorkspaceView'; +import WorkspaceManager from '../../model/model'; + +import { + createSimpleBrick, + createExpressionBrick, + createCompoundBrick, + resetFactoryCounter, +} from '../../../brick/utils/brickFactory'; + +const meta: Meta = { + title: 'Workspace/Fixed', + component: WorkspaceView, +}; +export default meta; + +type Story = StoryObj; + +// === Workspace Story 1: Single Tower (adapted from tree SingleBrick) === +export const SingleTower: Story = { + render: () => { + resetFactoryCounter(); + const manager = new WorkspaceManager(); + const brick = createSimpleBrick(); + manager.createTower(brick, { x: 100, y: 100 }); + return ; + }, +}; + +// === Workspace Story 2: Multiple Separate Towers (adapted from tree concept) === +export const MultipleTowers: Story = { + render: () => { + resetFactoryCounter(); + const manager = new WorkspaceManager(); + + // Tower 1: Simple stack (like tree StackedBricks) + const root1 = createSimpleBrick(); + const child1 = createSimpleBrick(); + const child2 = createSimpleBrick(); + const tower1 = manager.createTower(root1, { x: 100, y: 100 }); + tower1.addBrick(root1.uuid, child1, { x: 100, y: 130 }); + tower1.addBrick(child1.uuid, child2, { x: 100, y: 160 }); + + // Tower 2: Arguments (like tree ArgumentBricks) + const root2 = createSimpleBrick({ + label: 'My Simple', + bboxArgs: [ + { w: 60, h: 40 }, + { w: 60, h: 20 }, + ], + }); + const arg1 = createExpressionBrick({ label: 'Expr A', bboxArgs: [{ w: 60, h: 40 }] }); + const arg2 = createExpressionBrick({ label: 'Expr B', bboxArgs: [{ w: 60, h: 20 }] }); + const tower2 = manager.createTower(root2, { x: 400, y: 100 }); + tower2.addArgumentBrick(root2.uuid, arg1, { x: 0, y: 0 }, 0); + tower2.addArgumentBrick(root2.uuid, arg2, { x: 0, y: 0 }, 1); + + // Tower 3: Compound with nested (like tree CompoundWithNested) + const compound = createCompoundBrick({ + label: 'Compound with Nested', + bboxArgs: [{ w: 80, h: 40 }], + }); + const nested1 = createSimpleBrick(); + const nested2 = createSimpleBrick(); + const tower3 = manager.createTower(compound, { x: 700, y: 100 }); + tower3.addNestedBrick(compound.uuid, nested1, { x: 0, y: 0 }); + tower3.addNestedBrick(compound.uuid, nested2, { x: 0, y: 0 }); + + return ; + }, +}; + +// === Workspace Story 3: Full Composite Workspace (adapted from tree FullCompositeTree) === +export const FullCompositeWorkspace: Story = { + render: () => { + resetFactoryCounter(); + const manager = new WorkspaceManager(); + + // Tower 1: Full composite (like tree FullCompositeTree) + const compound1 = createCompoundBrick({ + label: 'Full Composite box with args', + bboxArgs: [ + { w: 100, h: 50 }, + { w: 120, h: 70 }, + ], + }); + const arg1 = createExpressionBrick({ label: 'Expr 1', bboxArgs: [{ w: 100, h: 50 }] }); + const arg2 = createExpressionBrick({ label: 'Expr 2', bboxArgs: [{ w: 120, h: 70 }] }); + const nested = createSimpleBrick(); + const stacked = createSimpleBrick(); + const tower1 = manager.createTower(compound1, { x: 100, y: 100 }); + tower1.addArgumentBrick(compound1.uuid, arg1, { x: 0, y: 0 }, 0); + tower1.addArgumentBrick(compound1.uuid, arg2, { x: 0, y: 0 }, 1); + tower1.addNestedBrick(compound1.uuid, nested, { x: 0, y: 0 }); + tower1.addBrick(compound1.uuid, stacked, { x: 100, y: 160 }); + + // Tower 2: Another compound + const compound2 = createCompoundBrick({ + label: 'Second Compound', + bboxArgs: [{ w: 80, h: 40 }], + }); + const tower2 = manager.createTower(compound2, { x: 500, y: 200 }); + tower2.addNestedBrick(compound2.uuid, createSimpleBrick(), { x: 0, y: 0 }); + + return ; + }, +}; + +// === Workspace Story 4: Argument Positioning Test (adapted from tree TestArgumentPositioning) === +export const TestArgumentPositioning: Story = { + render: () => { + resetFactoryCounter(); + const manager = new WorkspaceManager(); + + // Test with different label lengths + const shortLabel = createCompoundBrick({ + label: 'Short', + bboxArgs: Array(3).fill({ w: 60, h: 20 }), + }); + const longLabel = createCompoundBrick({ + label: 'Very Long Label That Should Push Args Further Right', + bboxArgs: Array(3).fill({ w: 60, h: 20 }), + }); + + const tower1 = manager.createTower(shortLabel, { x: 100, y: 50 }); + const tower2 = manager.createTower(longLabel, { x: 100, y: 200 }); + + // Add argument bricks to both + for (let i = 0; i < 3; i++) { + const expr1 = createExpressionBrick({ label: `Arg ${i + 1}` }); + const expr2 = createExpressionBrick({ label: `Arg ${i + 1}` }); + tower1.addArgumentBrick(shortLabel.uuid, expr1, { x: 0, y: 0 }, i); + tower2.addArgumentBrick(longLabel.uuid, expr2, { x: 0, y: 0 }, i); + } + + return ; + }, +};