From 13f8524de08d35af631ea6ea6542bfad95d94895 Mon Sep 17 00:00:00 2001 From: ceriphe <3409132+cirephe@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:33:56 -0600 Subject: [PATCH 1/4] Update top section --- src/components/landing/Intro.tsx | 53 ++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/src/components/landing/Intro.tsx b/src/components/landing/Intro.tsx index a9f352b83..a235d1899 100644 --- a/src/components/landing/Intro.tsx +++ b/src/components/landing/Intro.tsx @@ -4,14 +4,15 @@ export function Intro() { return (
-

- Build complex logic intelligently +

+ Turn ideas into diagrams and code in minutes.

-
-

- Your source of truth for visually creating, deploying, and analyzing - any type of logic, from frontend user flows to backend workflows. +

+ +

+ From frontend user flows to backend workflows, build and deploy any + type of logic with Stately as your source of truth.

@@ -31,6 +32,46 @@ export function Intro() { ); } +function ConversionBoxes() { + const boxStyles = + 'bg-gradient-to-b from-gray-700/50 to-gray-700/10 border-[0.5px] shadow-md shadow-blue-900 border-blue-850 rounded-2xl py-4 px-6 h-fit max-w-xs'; + const headerStyles = 'text-xl font-black'; + const listStyles = 'text-white/60 text-sm pt-1 font-medium space-y-1'; + return ( +
+
+

Ideas

+
    +
  • Requirements
  • +
  • User stories
  • +
  • Features
  • +
  • Specifications
  • +
+
+ +
+

Diagrams

+
    +
  • State machines
  • +
  • Flowcharts
  • +
  • Statecharts
  • +
  • Sequence diagrams
  • +
+
+ +
+

Code

+
    +
  • Workflows
  • +
  • App logic
  • +
  • JS, TS, JSON
  • +
  • Mermaid
  • +
+
+
+ ); +} + function CallToActionButtons() { return (
From 0c70760755975070b37bea7a91814de64dffb3d0 Mon Sep 17 00:00:00 2001 From: ceriphe <3409132+cirephe@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:39:45 -0600 Subject: [PATCH 2/4] update order --- src/components/landing/Intro.tsx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/landing/Intro.tsx b/src/components/landing/Intro.tsx index a235d1899..519cd991a 100644 --- a/src/components/landing/Intro.tsx +++ b/src/components/landing/Intro.tsx @@ -1,4 +1,4 @@ -import { ButtonLink } from './SharedComponents'; +import { ButtonLink, classNames } from './SharedComponents'; export function Intro() { return ( @@ -39,17 +39,7 @@ function ConversionBoxes() { const listStyles = 'text-white/60 text-sm pt-1 font-medium space-y-1'; return (
-
-

Ideas

-
    -
  • Requirements
  • -
  • User stories
  • -
  • Features
  • -
  • Specifications
  • -
-
- -
+

Diagrams

  • State machines
  • @@ -60,6 +50,16 @@ function ConversionBoxes() {
+

Ideas

+
    +
  • Requirements
  • +
  • User stories
  • +
  • Features
  • +
  • Specifications
  • +
+
+ +

Code

  • Workflows
  • From 0384a85c0e5e738317e0e47a04eea69d4a8717b5 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 5 Jan 2024 15:17:35 -0500 Subject: [PATCH 3/4] Arrows WIP --- src/components/landing/Intro.tsx | 325 ++++++++++++++++++++++- src/pages/arrows.ts | 0 src/pages/arrows/index.ts | 240 +++++++++++++++++ src/pages/arrows/path.ts | 437 +++++++++++++++++++++++++++++++ src/pages/arrows/rect.ts | 316 ++++++++++++++++++++++ src/pages/arrows/types.ts | 165 ++++++++++++ 6 files changed, 1469 insertions(+), 14 deletions(-) create mode 100644 src/pages/arrows.ts create mode 100644 src/pages/arrows/index.ts create mode 100644 src/pages/arrows/path.ts create mode 100644 src/pages/arrows/rect.ts create mode 100644 src/pages/arrows/types.ts diff --git a/src/components/landing/Intro.tsx b/src/components/landing/Intro.tsx index 519cd991a..afe42aef9 100644 --- a/src/components/landing/Intro.tsx +++ b/src/components/landing/Intro.tsx @@ -1,4 +1,16 @@ +import { useEffect, useLayoutEffect } from 'react'; import { ButtonLink, classNames } from './SharedComponents'; +import { Side } from '@site/src/pages/arrows/types'; +import { Rect } from '@site/src/pages/arrows/rect'; +import { + bendPath, + getInnerGridLines, + getLineSegmentsFromGridLines, + getSvgPathFromSegments, + oppositeSide, + pathToD, +} from '@site/src/pages/arrows/path'; +import { maybeStringToNumber } from '@site/src/pages/arrows/index'; export function Intro() { return ( @@ -32,24 +44,290 @@ export function Intro() { ); } +interface DrawnPath { + redraw: () => void; +} + +function useArrows( + config: Record< + string, + Array<{ + target: string; + sourceSide?: 'top' | 'right' | 'bottom' | 'left'; + targetSide?: 'top' | 'right' | 'bottom' | 'left'; + }> + >, +) { + useLayoutEffect(() => { + const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + svgEl.setAttribute( + 'style', + ` +position: absolute; +top: 0; +left: 0; +width: 100vw; +height: 100vh; +max-width: 100%; +overflow: visible; +pointer-events: none; +z-index: 2; +`, + ); + + const defEl = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'defs', + ); + + defEl.innerHTML = ` + + + +`; + svgEl.setAttribute('id', 'arrows'); + svgEl.appendChild(defEl); + document.body.appendChild(svgEl); + + return () => { + svgEl.remove(); + }; + }, []); + + useEffect(() => { + const nodeEls = + document.querySelectorAll('[data-edge-source]'); + const paths: DrawnPath[] = []; + const resizeObserverFns: Array<() => void> = []; + + function onResize(el: any, cb: (rect: Rect) => void) { + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + if (el.ownerDocument.contains(el)) { + cb(el.getBoundingClientRect()); + } + }); + }); + resizeObserver.observe(el); + + resizeObserverFns.push(() => resizeObserver.unobserve(el)); + } + + const svgEl = document.querySelector('#arrows')! as SVGElement; + + function drawPath(config: { + source: Element; + sourceSide: Side; + /** + * Distance (%) from left or top of source side. + * + * @default 0.5 + */ + sourcePosition?: number; + target: Element; + targetSide: Side; + /** + * Distance (%) from left or top of target side. + * + * @default 0.5 + */ + targetPosition?: number; + /** + * Distance (%) between start and end of zig-zag edge where it + * should cut across. + * + * @default 0.5 + */ + bendPosition?: number; + radius?: number; + color?: string; + attributes?: Record; + }): DrawnPath { + const resolvedConfig = { + radius: 10, + ...config, + }; + + const { source, target } = resolvedConfig; + + function getPathD() { + const svgRect = new Rect(svgEl.getBoundingClientRect()); + + const sourceRect = new Rect(source.getBoundingClientRect()); + const targetRect = new Rect(target.getBoundingClientRect()); + const startPoint = sourceRect.relativeSide( + resolvedConfig.sourceSide, + resolvedConfig.sourcePosition ?? 0.5, + ); + let endPoint = targetRect.relativeSide( + resolvedConfig.targetSide, + resolvedConfig.targetPosition ?? 0.5, + ); + + if ( + oppositeSide[resolvedConfig.sourceSide] === + resolvedConfig.targetSide && + resolvedConfig.targetPosition === undefined + ) { + if ( + ['top', 'bottom'].includes(resolvedConfig.targetSide) && + startPoint.x > targetRect.left && + startPoint.x < targetRect.right + ) { + endPoint.x = startPoint.x; + } else if ( + startPoint.y > targetRect.top && + startPoint.y < targetRect.bottom + ) { + endPoint.y = startPoint.y; + } + } + startPoint.y -= svgRect.top; + endPoint.y -= svgRect.top; + const lines = getInnerGridLines( + startPoint, + endPoint, + resolvedConfig.bendPosition ?? 0.5, + ); + const lineSegments = getLineSegmentsFromGridLines( + startPoint, + endPoint, + lines, + ); + + const svgPath = getSvgPathFromSegments( + lineSegments.allLineSegments, + startPoint, + endPoint, + ); + + const pathD = pathToD(bendPath(svgPath, resolvedConfig.radius)); + + return pathD; + } + + const pathEl = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path', + ); + + pathEl.setAttribute('stroke', resolvedConfig.color ?? 'white'); + pathEl.setAttribute('stroke-width', '2'); + pathEl.setAttribute('fill', 'none'); + pathEl.setAttribute('d', getPathD()); + pathEl.setAttribute('marker-end', 'url(#arrow)'); + + if (resolvedConfig.attributes) { + Object.entries(resolvedConfig.attributes).forEach(([key, value]) => { + pathEl.setAttribute(key, value); + }); + } + svgEl.appendChild(pathEl); + + const obj = { + redraw: () => { + pathEl.setAttribute('d', getPathD()); + }, + }; + + onResize(source, obj.redraw); + onResize(target, obj.redraw); + + return obj; + } + + Object.entries(config).forEach(([nodeKey, nodeConfig]) => { + const elNode = document.querySelector( + `[data-edge-source="${nodeKey}"]`, + ); + if (!elNode) { + return; + } + + nodeConfig.forEach((targetConfig) => { + const elTarget = document.querySelector( + `[data-edge-source="${targetConfig.target}"]`, + ); + + if (!elTarget) { + return; + } + + const sourceSide = targetConfig.sourceSide ?? 'bottom'; + const targetSide = targetConfig.targetSide ?? 'top'; + const sourcePosition = maybeStringToNumber( + elNode.dataset.edgeSourcePosition, + ); + const targetPosition = maybeStringToNumber( + elNode.dataset.edgeTargetPosition, + ); + const bendPosition = maybeStringToNumber( + elNode.dataset.edgeBendPosition, + ); + + paths.push( + drawPath({ + source: elNode, + sourceSide, + sourcePosition, + target: elTarget, + targetSide, + targetPosition, + bendPosition, + attributes: { + class: 'edge', + }, + radius: 20, + }), + ); + }); + }); + + const resizeHandler = () => { + paths.forEach((path) => path.redraw()); + }; + + window.addEventListener('resize', resizeHandler); + + return () => { + window.removeEventListener('resize', resizeHandler); + + resizeObserverFns.forEach((fn) => fn()); + }; + }); +} + function ConversionBoxes() { const boxStyles = - 'bg-gradient-to-b from-gray-700/50 to-gray-700/10 border-[0.5px] shadow-md shadow-blue-900 border-blue-850 rounded-2xl py-4 px-6 h-fit max-w-xs'; + 'bg-gradient-to-b from-gray-700/50 to-gray-700/10 border-[0.5px] shadow-md shadow-blue-900 border-blue-850 rounded-2xl py-4 px-6 h-fit w-80'; const headerStyles = 'text-xl font-black'; const listStyles = 'text-white/60 text-sm pt-1 font-medium space-y-1'; - return ( -
    -
    -

    Diagrams

    -
      -
    • State machines
    • -
    • Flowcharts
    • -
    • Statecharts
    • -
    • Sequence diagrams
    • -
    -
    + useArrows({ + diagrams: [ + { + target: 'code', + sourceSide: 'right', + targetSide: 'bottom', + }, + ], + }); -
    + return ( +
    +

    Ideas

    • Requirements
    • @@ -59,7 +337,26 @@ function ConversionBoxes() {
    -
    +
    +

    Diagrams

    +
      +
    • State machines
    • +
    • Flowcharts
    • +
    • Statecharts
    • +
    • Sequence diagrams
    • +
    +
    + +

    Code

    • Workflows
    • diff --git a/src/pages/arrows.ts b/src/pages/arrows.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/pages/arrows/index.ts b/src/pages/arrows/index.ts new file mode 100644 index 000000000..73e8d345d --- /dev/null +++ b/src/pages/arrows/index.ts @@ -0,0 +1,240 @@ +import { + bendPath, + getInnerGridLines, + getLineSegmentsFromGridLines, + getSvgPathFromSegments, + oppositeSide, + pathToD, +} from './path'; +import { Rect } from './rect'; +import { Side } from './types'; + +export function drawArrows() { + const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + svgEl.setAttribute( + 'style', + ` +position: absolute; +top: 0; +left: 0; +width: 100vw; +height: 100vh; +max-width: 100%; +overflow: visible; +pointer-events: none; +z-index: 2; +`, + ); + + const defEl = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + + defEl.innerHTML = ` + + + +`; + svgEl.appendChild(defEl); + document.body.appendChild(svgEl); + + interface DrawnPath { + redraw: () => void; + } + + function onResize(el: any, cb: (rect: Rect) => void) { + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + if (el.ownerDocument.contains(el)) { + cb(el.getBoundingClientRect()); + } + }); + }); + resizeObserver.observe(el); + + return () => resizeObserver.unobserve(el); + } + + function drawPath(config: { + source: Element; + sourceSide: Side; + /** + * Distance (%) from left or top of source side. + * + * @default 0.5 + */ + sourcePosition?: number; + target: Element; + targetSide: Side; + /** + * Distance (%) from left or top of target side. + * + * @default 0.5 + */ + targetPosition?: number; + /** + * Distance (%) between start and end of zig-zag edge where it + * should cut across. + * + * @default 0.5 + */ + bendPosition?: number; + radius?: number; + color?: string; + attributes?: Record; + }): DrawnPath { + const resolvedConfig = { + radius: 10, + ...config, + }; + + const { source, target } = resolvedConfig; + + function getPathD() { + const svgRect = new Rect(svgEl.getBoundingClientRect()); + + const sourceRect = new Rect(source.getBoundingClientRect()); + const targetRect = new Rect(target.getBoundingClientRect()); + const startPoint = sourceRect.relativeSide( + resolvedConfig.sourceSide, + resolvedConfig.sourcePosition ?? 0.5, + ); + let endPoint = targetRect.relativeSide( + resolvedConfig.targetSide, + resolvedConfig.targetPosition ?? 0.5, + ); + + if ( + oppositeSide[resolvedConfig.sourceSide] === resolvedConfig.targetSide && + resolvedConfig.targetPosition === undefined + ) { + if ( + ['top', 'bottom'].includes(resolvedConfig.targetSide) && + startPoint.x > targetRect.left && + startPoint.x < targetRect.right + ) { + endPoint.x = startPoint.x; + } else if ( + startPoint.y > targetRect.top && + startPoint.y < targetRect.bottom + ) { + endPoint.y = startPoint.y; + } + } + startPoint.y -= svgRect.top; + endPoint.y -= svgRect.top; + const lines = getInnerGridLines( + startPoint, + endPoint, + resolvedConfig.bendPosition ?? 0.5, + ); + const lineSegments = getLineSegmentsFromGridLines( + startPoint, + endPoint, + lines, + ); + + const svgPath = getSvgPathFromSegments( + lineSegments.allLineSegments, + startPoint, + endPoint, + ); + + const pathD = pathToD(bendPath(svgPath, resolvedConfig.radius)); + + return pathD; + } + + const pathEl = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path', + ); + + pathEl.setAttribute('stroke', resolvedConfig.color ?? 'white'); + pathEl.setAttribute('stroke-width', '2'); + pathEl.setAttribute('fill', 'none'); + pathEl.setAttribute('d', getPathD()); + pathEl.setAttribute('marker-end', 'url(#arrow)'); + + if (resolvedConfig.attributes) { + Object.entries(resolvedConfig.attributes).forEach(([key, value]) => { + pathEl.setAttribute(key, value); + }); + } + svgEl.appendChild(pathEl); + + const obj = { + redraw: () => { + pathEl.setAttribute('d', getPathD()); + }, + }; + + onResize(source, obj.redraw); + onResize(target, obj.redraw); + + return obj; + } + + const nodeEls = document.querySelectorAll('[data-edge-source]'); + const paths: DrawnPath[] = []; + + nodeEls.forEach((elNode) => { + const target = elNode.dataset.edgeTarget; + + if (!target) { + return; + } + + const elTarget = document.querySelector(`[data-edge-source="${target}"]`); + + if (!elTarget) { + return; + } + + const sourceSide = (elNode.dataset.edgeSourceSide as Side) ?? 'bottom'; + const targetSide = (elNode.dataset.edgeTargetSide as Side) ?? 'top'; + const sourcePosition = maybeStringToNumber( + elNode.dataset.edgeSourcePosition, + ); + const targetPosition = maybeStringToNumber( + elNode.dataset.edgeTargetPosition, + ); + const bendPosition = maybeStringToNumber(elNode.dataset.edgeBendPosition); + + paths.push( + drawPath({ + source: elNode, + sourceSide, + sourcePosition, + target: elTarget, + targetSide, + targetPosition, + bendPosition, + attributes: { + class: 'edge', + }, + radius: 20, + }), + ); + }); + + window.addEventListener('resize', () => { + paths.forEach((path) => path.redraw()); + }); + + onResize(document.body, () => { + paths.forEach((path) => path.redraw()); + }); +} + +export function maybeStringToNumber(string?: string): number | undefined { + return string !== undefined ? +string : undefined; +} diff --git a/src/pages/arrows/path.ts b/src/pages/arrows/path.ts new file mode 100644 index 000000000..66bd391b9 --- /dev/null +++ b/src/pages/arrows/path.ts @@ -0,0 +1,437 @@ +import type { + SidePoint, + GridLines, + SidePath, + LineSegment, + GridLine, + Point, + Ray, + Side, + SideDirection, + XGridLine, + YGridLine, + SvgPath, + SvgPathPortion, + CubicCurve, + Vector, +} from './types'; + +export const sideFactor: Record = { + left: { x: -1, y: 0, direction: 'vertical' }, + right: { x: 1, y: 0, direction: 'vertical' }, + top: { x: 0, y: -1, direction: 'horizontal' }, + bottom: { x: 0, y: 1, direction: 'horizontal' }, +}; + +export const oppositeSide: Record = { + left: 'right', + right: 'left', + top: 'bottom', + bottom: 'top', +}; + +export function sidePointToRay(sidePoint: SidePoint): Ray { + const ray = { + x: sidePoint.x, + y: sidePoint.y, + dx: sideFactor[sidePoint.side].x, + dy: sideFactor[sidePoint.side].y, + }; + + return ray; +} + +function doOrthoRaysIntersect(ray1: Ray, ray2: Ray) { + if ((ray1.dx === 0 && ray2.dx === 0) || (ray1.dy === 0 && ray2.dy === 0)) { + // parallel; TODO: deal with collinear rays + return false; + } + + return ( + ray1.x * ray1.dx <= ray2.x * ray1.dx && + ray1.y * ray1.dy <= ray2.y * ray1.dy && + ray2.x * ray2.dx <= ray1.x * ray2.dx && + ray2.y * ray2.dy <= ray1.y * ray2.dy + ); +} + +function sidePathsMatchDirection( + sidePath1: SidePath, + sidePathDirections: [SideDirection, SideDirection], +): boolean { + return ( + sideFactor[sidePath1[0]].direction === sidePathDirections[0] && + sideFactor[sidePath1[1]].direction === sidePathDirections[1] + ); +} + +export function getInnerGridLines( + sourcePoint: SidePoint, + targetPoint: SidePoint, + bendRatio: number = 0.5, +): GridLines { + const { side: sourceSide } = sourcePoint; + const { side: targetSide } = targetPoint; + const sidePath: SidePath = [sourceSide, targetSide]; + const padding = 20; // TODO: don't hardcode + + const sourceRay = sidePointToRay(sourcePoint); + const targetRay = sidePointToRay(targetPoint); + + if (doOrthoRaysIntersect(sourceRay, targetRay)) { + return []; + } + + if (sidePathsMatchDirection(sidePath, ['vertical', 'horizontal'])) { + return [ + { x: sourcePoint.x + padding * sideFactor[sourceSide].x }, + { y: targetPoint.y + padding * sideFactor[targetSide].y }, + ]; + } + + if (sidePathsMatchDirection(sidePath, ['horizontal', 'vertical'])) { + return [ + { y: sourcePoint.y + padding * sideFactor[sourceSide].y }, + { x: targetPoint.x + padding * sideFactor[targetSide].x }, + ]; + } + + if (sidePathsMatchDirection(sidePath, ['vertical', 'vertical'])) { + const xFactor = sideFactor[sourceSide].x; + + if (oppositeSide[sourceSide] === targetSide) { + if (xFactor * sourcePoint.x < xFactor * targetPoint.x) { + return [ + { x: sourcePoint.x + (targetPoint.x - sourcePoint.x) * bendRatio }, + ]; + } else { + return [ + { x: sourcePoint.x + padding * xFactor }, + { y: sourcePoint.y + (targetPoint.y - sourcePoint.y) * bendRatio }, + { x: targetPoint.x + padding * sideFactor[targetSide].x }, + ]; + } + } + + return [ + { + x: + xFactor * Math.max(xFactor * sourcePoint.x, xFactor * targetPoint.x) + + padding * xFactor, + }, + ]; + } + + if (sidePathsMatchDirection(sidePath, ['horizontal', 'horizontal'])) { + const yFactor = sideFactor[sourceSide].y; + + if (oppositeSide[sourceSide] === targetSide) { + if (yFactor * sourcePoint.y < yFactor * targetPoint.y) { + return [ + { y: sourcePoint.y + (targetPoint.y - sourcePoint.y) * bendRatio }, + ]; + } else { + return [ + { y: sourcePoint.y + padding * yFactor }, + { x: sourcePoint.x + (targetPoint.x - sourcePoint.x) * bendRatio }, + { y: targetPoint.y + padding * sideFactor[targetSide].y }, + ]; + } + } + + return [ + { + y: + yFactor * Math.max(yFactor * sourcePoint.y, yFactor * targetPoint.y) + + padding * yFactor, + }, + ]; + } + return []; +} + +export function getLineSegmentsFromGridLines( + sourcePoint: SidePoint, + targetPoint: SidePoint, + gridLines: GridLine[], +): { + sourceLineSegment: LineSegment; + lineSegments: LineSegment[]; + targetLineSegment: LineSegment; + allLineSegments: LineSegment[]; +} { + const allGridLines = [ + ['top', 'bottom'].includes(sourcePoint.side) + ? { x: sourcePoint.x } + : { y: sourcePoint.y }, + ...gridLines, + ['top', 'bottom'].includes(targetPoint.side) + ? { x: targetPoint.x } + : { y: targetPoint.y }, + ]; + + const pointToLine = ( + point: Point, + line: T, + nextLine: T extends XGridLine ? YGridLine : XGridLine, + ): LineSegment => { + const lineVertical = 'x' in line; + + return [ + point, + lineVertical + ? { x: line.x, y: (nextLine as YGridLine).y } + : { x: (nextLine as XGridLine).x, y: line.y }, + ]; + }; + + let currentPoint = + 'x' in allGridLines[0] + ? { x: allGridLines[0].x, y: sourcePoint.y } + : { x: sourcePoint.x, y: allGridLines[0].y }; + const segments: LineSegment[] = []; + + for (let i = 0; i < allGridLines.length; i++) { + const line = allGridLines[i]; + const nextLine = + allGridLines[i + 1] ?? + ('x' in line ? { y: targetPoint.y } : { x: targetPoint.x }); + const nextSegment = pointToLine(currentPoint, line, nextLine); + segments.push(nextSegment); + currentPoint = nextSegment[1]; + } + + return { + sourceLineSegment: segments[0], + lineSegments: segments.slice(1, -1), + targetLineSegment: segments[segments.length - 1], + allLineSegments: segments, + }; +} + +export function getSvgPathFromSegments( + segments: LineSegment[], + sourcePoint: SidePoint, + targetPoint: SidePoint, + padding: number = 10, +): SvgPath { + // @ts-ignore + const path: SvgPath = []; + + segments.forEach((segment, i) => { + if (!path.length) { + path.push(['M', segment[0]]); + } + + // if last segment + if (i === segments.length - 1) { + const preEndPoint = { ...segment[1] }; + switch (targetPoint.side) { + case 'left': + preEndPoint.x -= padding; + break; + case 'right': + preEndPoint.x += padding; + break; + case 'top': + preEndPoint.y -= padding; + break; + case 'bottom': + preEndPoint.y += padding; + break; + default: + break; + } + path.push(['L', preEndPoint]); + } else { + path.push(['L', segment[1]]); + } + }); + + return path; +} + +export function pathToD(path: SvgPath): string { + return path + .map(([cmd, ...points]) => + [ + cmd, + ...points.map((point: Point | number) => { + if (typeof point === 'number') { + return point; + } + return `${point.x},${point.y}`; + }), + ].join(' '), + ) + .join(' '); +} + +export function bendPath(path: SvgPath, radius: number): SvgPath { + const contiguousLinePaths: SvgPathPortion[] = []; + const bentPath = [] as unknown as SvgPath; + const current: SvgPathPortion = []; + + for (const svgPoint of path) { + const [cmd] = svgPoint; + if (!['L', 'H', 'V'].includes(cmd)) { + if (current.length > 1) { + contiguousLinePaths.push([...current]); + current.length = 0; + } + } + + current.push(svgPoint); + } + if (current.length > 1) { + contiguousLinePaths.push([...current]); + } + + for (const linePath of contiguousLinePaths) { + const points = linePath.map(([, point]) => point as Point); // TODO: fix + const pointsWithMid = withMidpoints(simplifyPoints(points)); + + const bentPathPortion: SvgPath = [linePath[0] as any /* TODO */]; + + pointsWithMid.forEach((pt, i, pts) => { + if ( + i >= 2 && + i <= pts.length - 2 && + isBendable(pts[i - 1], pt, pts[i + 1]) + ) { + const { p1, p2, p } = roundOneCorner( + pts[i - 1], + pt, + pts[i + 1], + radius, + ); + + bentPathPortion.push(['L', p1], ['C', p1, p, p2]); + } else if (i > 0) { + bentPathPortion.push(['L', pt]); + } + }); + + bentPath.push(...bentPathPortion); + } + + return bentPath; +} + +export function withMidpoints(points: Point[]): Point[] { + const pointsWithMid: Point[] = []; + + points.forEach((pt, i) => { + const [ptA, ptB, ptC] = [pt, points[i + 1], points[i + 2]]; + + if (!ptC || !ptB) { + pointsWithMid.push(ptA); + return; + } + + const midpt = { + x: ptA.x + (ptB.x - ptA.x) / 2, + y: ptA.y + (ptB.y - ptA.y) / 2, + }; + + pointsWithMid.push(ptA, midpt); + }); + + return pointsWithMid; +} + +export function simplifyPoints(points: Point[]): Point[] { + const pointHashes = new Set(); + + const result: Point[] = []; + + points.forEach((point, i) => { + const prevPoint = points[i - 1]; + const nextPoint = points[i + 1]; + + if (prevPoint?.x === point.x && point.x === nextPoint?.x) { + return; + } + if (prevPoint?.y === point.y && point.y === nextPoint?.y) { + return; + } + + const hash = `${point.x}|${point.y}`; + + if (pointHashes.has(hash)) { + return; + } + + result.push(point); + }); + + return result; +} + +const lineToVector = (p1: Point, p2: Point): Vector => { + const vector = { + type: 'vector' as const, + x: p2.x - p1.x, + y: p2.y - p1.y, + }; + + return vector; +}; + +const vectorToUnitVector = (v: Vector): Vector => { + let magnitude = v.x * v.x + v.y * v.y; + magnitude = Math.sqrt(magnitude); + const unitVector = { + type: 'vector' as const, + x: v.x / magnitude, + y: v.y / magnitude, + }; + return unitVector; +}; + +export const roundOneCorner = ( + p1: Point, + corner: Point, + p2: Point, + radius: number, +): CubicCurve => { + const corner_to_p1 = lineToVector(corner, p1); + const corner_to_p2 = lineToVector(corner, p2); + const p1dist = Math.hypot(corner_to_p1.x, corner_to_p1.y); + const p2dist = Math.hypot(corner_to_p2.x, corner_to_p2.y); + if (p1dist * p2dist === 0) { + return { + p1: corner, + p2: corner, + p: corner, + }; + } + const resolvedRadius = Math.min(radius, p1dist - 0.1, p2dist - 0.1); + const corner_to_p1_unit = vectorToUnitVector(corner_to_p1); + const corner_to_p2_unit = vectorToUnitVector(corner_to_p2); + + const curve_p1 = { + x: corner.x + corner_to_p1_unit.x * resolvedRadius, + y: corner.y + corner_to_p1_unit.y * resolvedRadius, + }; + const curve_p2 = { + x: corner.x + corner_to_p2_unit.x * resolvedRadius, + y: corner.y + corner_to_p2_unit.y * resolvedRadius, + }; + const path = { + p1: curve_p1, + p2: curve_p2, + p: corner, + }; + + return path; +}; + +export function isBendable(p1: Point, corner: Point, p2: Point): boolean { + return !( + (p1.x === corner.x && p2.x === corner.x) || + (p1.y === corner.y && p2.y === corner.y) + ); +} + +// ...... diff --git a/src/pages/arrows/rect.ts b/src/pages/arrows/rect.ts new file mode 100644 index 000000000..a7535ad55 --- /dev/null +++ b/src/pages/arrows/rect.ts @@ -0,0 +1,316 @@ +import { + LineSegment, + RectInit, + SidePoint, + RelativeSidePoint, + Side, +} from './types'; + +export interface Point { + [key: string]: any; + x: number; + y: number; +} + +export type PartialRect = + | (({ left: number } | { right: number }) & + ({ top: number } | { bottom: number }) & { + width: number; + height: number; + }) + | { left: number; right: number; top: number; bottom: number }; + +/** + * A box is represented by two corner points + */ +export type Box = [Point, Point]; + +export class Rect implements DOMRect { + public top: number; + public left: number; + public bottom: number; + public right: number; + public width: number; + public height: number; + public x: number; + public y: number; + + public toJSON() { + const { top, left, bottom, right, width, height, x, y } = this; + return { top, left, bottom, right, width, height, x, y }; + } + + constructor(box: Box); + constructor(rect: RectInit | PartialRect); + constructor(rect: Box | RectInit | PartialRect) { + if (Array.isArray(rect)) { + const minX = Math.min(rect[0].x, rect[1].x); + const width = Math.max(rect[0].x, rect[1].x) - minX; + const minY = Math.min(rect[0].y, rect[1].y); + const height = Math.max(rect[0].y, rect[1].y) - minY; + this.top = minY; + this.left = minX; + this.width = width; + this.right = this.left + this.width; + this.height = height; + this.bottom = this.top + this.height; + this.x = this.left; + this.y = this.top; + } else { + this.top = + 'top' in rect + ? rect.top + : 'y' in rect + ? rect.y + : rect.bottom - rect.height; + this.left = + 'left' in rect + ? rect.left + : 'x' in rect + ? rect.x + : rect.right - rect.width; + this.bottom = 'bottom' in rect ? rect.bottom : this.top + rect.height; + this.right = 'right' in rect ? rect.right : this.left + rect.width; + this.width = this.right - this.left; + this.height = this.bottom - this.top; + this.x = this.left; + this.y = this.top; + } + } + + public point(x: string, y: string, meta?: Record): Point { + const point: Point = { x: 0, y: 0, ...meta }; + + switch (x) { + case 'left': + point.x = this.left; + break; + case 'right': + point.x = this.right; + break; + + case 'center': + point.x = this.left + this.width / 2; + break; + default: + break; + } + switch (y) { + case 'top': + point.y = this.top; + break; + case 'bottom': + point.y = this.bottom; + break; + + case 'center': + point.y = this.top + this.height / 2; + break; + default: + break; + } + + return point; + } + + public get center(): Point { + return { + x: this.left + this.width / 2, + y: this.top + this.height / 2, + }; + } + + public centerSide(side: Side, offset: number = 0): SidePoint { + switch (side) { + case 'left': + return { side: 'left', x: this.left - offset, y: this.center.y }; + case 'right': + return { side: 'right', x: this.right + offset, y: this.center.y }; + case 'top': + return { side: 'top', x: this.center.x, y: this.top - offset }; + case 'bottom': + return { side: 'bottom', x: this.center.x, y: this.bottom + offset }; + } + } + + public relativeSide(side: Side, percent: number): SidePoint { + switch (side) { + case 'left': + case 'right': + return { side, x: this[side], y: this.y + this.height * percent }; + case 'top': + case 'bottom': + return { side, x: this.x + this.width * percent, y: this[side] }; + } + } + + public getRelativeSidePoint(sidePoint: SidePoint): RelativeSidePoint { + const { side } = sidePoint; + switch (side) { + case 'left': + case 'right': { + const percent = + this.height === 0 ? 0.5 : (sidePoint.y - this.y) / this.height; + return { side, percent }; + } + case 'top': + case 'bottom': { + const percent = + this.width === 0 ? 0.5 : (sidePoint.x - this.x) / this.width; + + return { side, percent }; + } + } + } + + public sideSegment(side: Side): LineSegment { + switch (side) { + case 'left': + return [ + { x: this.left, y: this.top }, + { x: this.left, y: this.bottom }, + ]; + case 'right': + return [ + { x: this.right, y: this.top }, + { x: this.right, y: this.bottom }, + ]; + case 'top': + return [ + { x: this.left, y: this.top }, + { x: this.right, y: this.top }, + ]; + case 'bottom': + return [ + { x: this.left, y: this.bottom }, + { x: this.right, y: this.bottom }, + ]; + default: + throw new Error('Invalid side'); + } + } + + public equals(otherRect: DOMRect): boolean { + return [ + 'top' as const, + 'left' as const, + 'bottom' as const, + 'right' as const, + ].every((prop) => { + return otherRect[prop] === this[prop]; + }); + } + + public translate(dx: number, dy: number): Rect { + return new Rect({ + left: this.left + dx, + top: this.top + dy, + width: this.width, + height: this.height, + }); + } + + public moveTo(point: Point): Rect { + return new Rect({ + left: point.x, + top: point.y, + width: this.width, + height: this.height, + }); + } + + public withPadding(horizontal: number, vertical: number = horizontal): Rect { + return new Rect({ + left: this.left - horizontal, + right: this.right + horizontal, + top: this.top - vertical, + bottom: this.bottom + vertical, + }); + } + + public getBox(): Box { + return [ + { x: this.left, y: this.top }, + { x: this.right, y: this.bottom }, + ]; + } +} + +export function fromPoint(point: Point, width: number, height: number): Rect { + return new Rect({ + left: point.x, + top: point.y, + width, + height, + bottom: point.y + height, + right: point.x + width, + }); +} + +export function rectFrom(domRect: DOMRect): Rect { + return new Rect(domRect); +} + +export function relative(childRect: Rect, parentRect?: Rect): Rect { + if (!parentRect) { + return childRect; + } + + return new Rect({ + top: childRect.top - parentRect.top, + right: childRect.right - parentRect.left, + bottom: childRect.bottom - parentRect.top, + left: childRect.left - parentRect.left, + width: childRect.width, + height: childRect.height, + }); +} + +export function pointOnSide( + rect: Rect, + side: Side, + percent: number, +): SidePoint { + let segment: LineSegment; + let xFactor = 0, + yFactor = 0; + + switch (side) { + case 'left': + segment = [ + { x: rect.left, y: rect.top }, + { x: rect.left, y: rect.bottom }, + ]; + yFactor = percent; + break; + case 'right': + segment = [ + { x: rect.right, y: rect.top }, + { x: rect.right, y: rect.bottom }, + ]; + yFactor = percent; + break; + case 'top': + segment = [ + { x: rect.left, y: rect.top }, + { x: rect.right, y: rect.top }, + ]; + xFactor = percent; + break; + case 'bottom': + segment = [ + { x: rect.left, y: rect.bottom }, + { x: rect.right, y: rect.bottom }, + ]; + xFactor = percent; + break; + default: + throw new Error(`Invalid side: ${side}`); + } + + return { + x: (segment[1].x - segment[0].x) * xFactor + segment[0].x, + y: (segment[1].y - segment[0].y) * yFactor + segment[0].y, + side, + }; +} diff --git a/src/pages/arrows/types.ts b/src/pages/arrows/types.ts new file mode 100644 index 000000000..95881d464 --- /dev/null +++ b/src/pages/arrows/types.ts @@ -0,0 +1,165 @@ +type Brand = T & { __tag: Tag }; + +export type SaveStatus = 'saving' | 'saved' | 'idle' | 'none'; + +export interface DeltaPosition { + dx: number; + dy: number; +} + +export interface Size { + width: number; + height: number; +} + +export type Side = 'left' | 'right' | 'top' | 'bottom'; + +export interface Port { + side: Side; + /** + * Relative position from side's top/left position + */ + position: DeltaPosition; +} + +export interface Vector { + x: number; + y: number; +} + +export type SVGPoint = Brand; +export type ScreenPoint = Brand; + +/** displacement vector */ +export type SVGVector = Brand; +/** displacement vector */ +export type ScreenVector = Brand; + +export type ZoomValue = Brand; +export type ZoomFactor = Brand; + +export interface ViewBox { + minX: number; + minY: number; + width: number; + height: number; +} + +export type LineSegment = [Point, Point]; + +export interface SidePoint extends Point { + side: Side; +} + +export interface RelativeSidePoint { + /** + * The rect side this point is on + */ + side: Side; + /** + * How far from the top or left this point resides on the side + * E.g., if side is "right" and pecent is 0.1, this point is + * near the top-right corner on the right side + */ + percent: number; +} + +export type SideLineSegment = [SidePoint, SidePoint]; + +export type Path = Point[]; + +export type MPathParam = ['M', Point]; +export type LPathParam = ['L', Point]; +export type HPathParam = ['H', number]; +export type VPathParam = ['V', number]; +export type ZPathParam = ['Z']; +export type CPathParam = ['C', Point, Point, Point]; +export type QPathParam = ['Q', Point, Point]; +export type PathParam = + | MPathParam + | LPathParam + | HPathParam + | VPathParam + | ZPathParam + | CPathParam + | QPathParam; + +export type SvgPathPortion = PathParam[]; +export type SvgPath = [MPathParam, ...SvgPathPortion]; + +export interface RectInit { + x: number; + y: number; + width: number; + height: number; +} + +export interface ResultBox { + v: T; +} + +export type DeepPartial = { + [K in keyof T]?: DeepPartial; +}; + +export type JSONSerializable = T & { + toJSON: () => U; +}; + +export interface Spacing { + top: number; + right: number; + bottom: number; + left: number; +} + +export type Point = { + x: number; + y: number; +}; + +export type XGridLine = { x: number }; +export type YGridLine = { y: number }; +export type GridLine = XGridLine | YGridLine; +// TODO: hope that this gets fixed in TS +// type GridLinesX = [XGridLine, ...GridLinesY] +// type GridLinesY = [YGridLine, ...GridLinesX] +export type GridLines = GridLine[]; + +export type GridLineSegment = { + direction: SideDirection; + segment: LineSegment; +}; + +export type OrthogonalPath = { + sourceSide: Side; + targetSide: Side; + lines: GridLines; +}; + +/** + * Source node side and target node side to draw a path + */ +export type SidePath = [Side, Side]; + +export type SideDirection = 'vertical' | 'horizontal'; + +export interface Ray { + x: number; + y: number; + dx: number; + dy: number; +} + +export interface OrthogonalRay { + x: number; + y: number; + dx: -1 | 0 | 1; + dy: -1 | 0 | 1; +} + +export interface CubicCurve { + p1: Point; + p2: Point; + p: Point; +} From 0bc09b0c7324761ce7bdf082aafe4c6537e3538c Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 15 Jan 2024 21:06:30 -0500 Subject: [PATCH 4/4] Media WIP --- src/components/landing/Intro.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/landing/Intro.tsx b/src/components/landing/Intro.tsx index afe42aef9..7fab23d0b 100644 --- a/src/components/landing/Intro.tsx +++ b/src/components/landing/Intro.tsx @@ -55,6 +55,8 @@ function useArrows( target: string; sourceSide?: 'top' | 'right' | 'bottom' | 'left'; targetSide?: 'top' | 'right' | 'bottom' | 'left'; + // used for window.matchMedia(media) + media?: string; }> >, ) { @@ -318,6 +320,7 @@ function ConversionBoxes() { target: 'code', sourceSide: 'right', targetSide: 'bottom', + media: '(min-width: 1024px)', }, ], });