feat(website): connectable hero canvas + L-to-R layout

- Tighten the cluster into a clean left-to-right flow: input on the
  left, canny+depth stacked in the middle column with the slider on the
  green→depth path, final output on the right. Re-tune CONTENT bounds
  for ~80 scene-units of breathing room around the cluster.

- Make the canny→output and depth→output connections interactive.
  Click the CANNY EDGE / DEPTH MAP chip on the final-output card to
  toggle that connection (the chip is exactly where the input slot
  lives, giving a generous click target). Cursor swaps to pointer over
  a chip; disconnected chips dim to warm-gray and their edge line is
  hidden.

- Output mode follows the connection state — both connected shows the
  composed final image (with the existing depth-blur scrubber), only
  canny shows the canny-edge view, only depth shows the depth-map
  view, neither shows an empty placeholder card.

- Keep the canvas wrapper at full bleed and lift just the BrandButton
  to z-20 so drag interactions span the entire hero.
This commit is contained in:
Marwan Mostafa
2026-05-07 12:57:39 +03:00
parent 7b45c93bb5
commit af86b56c77
2 changed files with 138 additions and 66 deletions

View File

@@ -35,35 +35,48 @@ type SvgNode = NodeBase & {
type NodeDef = ImageNode | PurpleNode | SvgNode
type ToggleEdgeId = 'canny-out' | 'depth-out'
type Edge = {
id?: ToggleEdgeId
src: NodeId
sfx: number
sfy: number
tgt: NodeId
tfx: number
tfy: number
togglable?: boolean
connected?: boolean
}
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
// Bounding box of the actual node content in scene coordinates
// (x: 1002045, y: 1001059). fitView scales this box — not the outer
// 2150×1260 world — so nodes fill the container instead of stranding empty
// margins around them on wide heroes.
const CONTENT_MIN_X = 100
const CONTENT_MAX_X = 2045
// Bounding box of the actual node content in scene coordinates. fitView
// scales this box to fit the container (with a small safety margin), so
// asymmetry here translates directly into where the cluster sits in the
// canvas. The bounds are tuned so each cluster edge has ~80 scene units of
// breathing room before the fit edge.
const CONTENT_MIN_X = 60
const CONTENT_MAX_X = 2055
const CONTENT_MIN_Y = 100
const CONTENT_MAX_Y = 1059
const CONTENT_MAX_Y = 1109
const CONTENT_W = CONTENT_MAX_X - CONTENT_MIN_X
const CONTENT_H = CONTENT_MAX_Y - CONTENT_MIN_Y
const CONTENT_CX = (CONTENT_MIN_X + CONTENT_MAX_X) / 2
const CONTENT_CY = (CONTENT_MIN_Y + CONTENT_MAX_Y) / 2
// At lg+ widths, HeroSection's text column maxes out at lg:max-w-xl (36rem)
// inside lg:p-16 (4rem) padding. Reserve that slice on the right so the
// cluster fits on the left without overlapping the headline / paragraph / CTA.
const TEXT_COLUMN_RESERVE_CSS = 640
const INK = '#211927'
const YELLOW = '#F2FF59'
const CANVAS_COLOR = '#C2BFB9'
const COOL_GRAY = '#3C3C3C'
const WARM_GRAY = '#7e7c78'
const SLOT_DOT_HIT_RADIUS = 26
const svgOutputRaw = `<svg width="386" height="116" viewBox="0 0 193 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="193" height="58" rx="16.3661" fill="#7E7C78"/>
@@ -245,7 +258,26 @@ function drawNode(ctx: CanvasRenderingContext2D, id: NodeId) {
if (n.type === 'purple') {
const outputUi = nodes['n-output-ui'] as SvgNode
drawDepthBlur(ctx, x, y, n.w, n.h, n.rx, n.img, outputUi.progress)
const cannyOn = isEdgeConnected('canny-out')
const depthOn = isEdgeConnected('depth-out')
// Choose what the output card shows based on which inputs are wired in:
// both → final composed image (with depth-blur scrubber)
// one → that single processed view, full-bleed, no scrub
// none → empty card placeholder
if (cannyOn && depthOn) {
drawDepthBlur(ctx, x, y, n.w, n.h, n.rx, n.img, outputUi.progress)
} else if (cannyOn) {
drawImageCover(ctx, x, y, n.w, n.h, n.rx, INK, imgRed, null)
} else if (depthOn) {
drawImageCover(ctx, x, y, n.w, n.h, n.rx, INK, imgBlue, null)
} else {
ctx.save()
roundRect(ctx, x, y, n.w, n.h, n.rx)
ctx.fillStyle = COOL_GRAY
ctx.fill()
ctx.restore()
}
const chipX = 24
const chipW = 175
@@ -257,51 +289,29 @@ function drawNode(ctx: CanvasRenderingContext2D, id: NodeId) {
const dotR = 5
const textOffsetX = 38
roundRect(ctx, x + chipX, y + chip1Y, chipW, chipH, chipR)
ctx.fillStyle = INK
ctx.fill()
const drawChip = (labelY: number, label: string, active: boolean) => {
roundRect(ctx, x + chipX, y + labelY, chipW, chipH, chipR)
ctx.fillStyle = INK
ctx.fill()
ctx.beginPath()
ctx.arc(
x + chipX + dotOffsetX,
y + chip1Y + chipH / 2,
dotR,
0,
Math.PI * 2
)
ctx.fillStyle = YELLOW
ctx.fill()
ctx.beginPath()
ctx.arc(
x + chipX + dotOffsetX,
y + labelY + chipH / 2,
dotR,
0,
Math.PI * 2
)
ctx.fillStyle = active ? YELLOW : WARM_GRAY
ctx.fill()
ctx.font = "800 14px 'PP Formula', sans-serif"
ctx.fillStyle = YELLOW
ctx.fillText(
'CANNY EDGE',
x + chipX + textOffsetX,
y + chip1Y + chipH / 2 + 5
)
ctx.font = "800 14px 'PP Formula', sans-serif"
ctx.fillStyle = active ? YELLOW : WARM_GRAY
ctx.fillText(label, x + chipX + textOffsetX, y + labelY + chipH / 2 + 5)
}
roundRect(ctx, x + chipX, y + chip2Y, chipW, chipH, chipR)
ctx.fillStyle = INK
ctx.fill()
ctx.beginPath()
ctx.arc(
x + chipX + dotOffsetX,
y + chip2Y + chipH / 2,
dotR,
0,
Math.PI * 2
)
ctx.fillStyle = YELLOW
ctx.fill()
ctx.font = "800 14px 'PP Formula', sans-serif"
ctx.fillStyle = YELLOW
ctx.fillText(
'DEPTH MAP',
x + chipX + textOffsetX,
y + chip2Y + chipH / 2 + 5
)
drawChip(chip1Y, 'CANNY EDGE', cannyOn)
drawChip(chip2Y, 'DEPTH MAP', depthOn)
} else if (n.type === 'image') {
const outputUi = nodes['n-output-ui'] as SvgNode
const filter = id === 'n-blue' ? levelsFilter(outputUi.progress) : null
@@ -336,6 +346,8 @@ function drawNode(ctx: CanvasRenderingContext2D, id: NodeId) {
}
function drawEdge(ctx: CanvasRenderingContext2D, e: Edge) {
if (e.connected === false) return
const a = anchor(e.src, e.sfx, e.sfy)
const b = anchor(e.tgt, e.tfx, e.tfy)
@@ -405,10 +417,15 @@ function fitView() {
// Fit the node bounding box (not the full world) into the container with a
// small safety margin, and center it. This prevents wide/short heroes from
// leaving huge empty gutters around the scene.
const z = Math.min(cssW / CONTENT_W, cssH / CONTENT_H) * 0.92 * dpr
// leaving huge empty gutters around the scene. From lg up, reserve the
// right slice of the hero for the text column so the cluster initially
// sits on the left half — drag still works across the full canvas because
// canvas.width/height stay at full bleed.
const rightReserveCss = cssW >= 1024 ? TEXT_COLUMN_RESERVE_CSS : 0
const visibleW = cssW - rightReserveCss
const z = Math.min(visibleW / CONTENT_W, cssH / CONTENT_H) * 0.92 * dpr
vz = z
vx = canvas.width / 2 - CONTENT_CX * z
vx = (visibleW * dpr) / 2 - CONTENT_CX * z
vy = canvas.height / 2 - CONTENT_CY * z
// Keep every node inside the newly-sized viewport so a resize can't strand
@@ -458,6 +475,41 @@ function clientToWorld(cx: number, cy: number) {
return { x: (px - vx) / vz, y: (py - vy) / vz }
}
function isEdgeConnected(id: ToggleEdgeId): boolean {
const edge = edges?.find((e) => e.id === id)
return edge?.connected !== false
}
// Toggle hit-test for the two interactive connections.
// Click targets, in priority order:
// 1. The CANNY EDGE / DEPTH MAP chips on the purple card (~175×44 each —
// these chips sit exactly where the input slot dots live).
// 2. The smaller output slot dots on the right edge of canny / depth.
function hitTestToggle(wx: number, wy: number): ToggleEdgeId | null {
if (nodes && nodes['n-purple']) {
const p = absPos('n-purple')
const chipX = p.x + 24
const chipW = 175
const chipH = 44
if (wx >= chipX && wx <= chipX + chipW) {
if (wy >= p.y + 42 && wy <= p.y + 42 + chipH) return 'canny-out'
if (wy >= p.y + 100 && wy <= p.y + 100 + chipH) return 'depth-out'
}
}
if (edges) {
for (const edge of edges) {
if (!edge.id || !edge.togglable) continue
const a = anchor(edge.src, edge.sfx, edge.sfy)
const dx = wx - a.x
const dy = wy - a.y
if (dx * dx + dy * dy < SLOT_DOT_HIT_RADIUS * SLOT_DOT_HIT_RADIUS) {
return edge.id
}
}
}
return null
}
function hitTest(wx: number, wy: number): NodeId | null {
const order: NodeId[] = [
'n-output-ui',
@@ -491,6 +543,17 @@ function onPointerDown(e: PointerEvent) {
const canvas = canvasRef.value
if (!canvas) return
const w = clientToWorld(e.clientX, e.clientY)
const toggleId = hitTestToggle(w.x, w.y)
if (toggleId && edges) {
const edge = edges.find((edge) => edge.id === toggleId)
if (edge) {
edge.connected = !edge.connected
draw()
return
}
}
const hit = hitTest(w.x, w.y)
if (!hit) return
@@ -530,7 +593,8 @@ function onPointerMove(e: PointerEvent) {
draw()
} else {
const w = clientToWorld(e.clientX, e.clientY)
canvas.style.cursor = hitTest(w.x, w.y) ? 'grab' : 'default'
if (hitTestToggle(w.x, w.y)) canvas.style.cursor = 'pointer'
else canvas.style.cursor = hitTest(w.x, w.y) ? 'grab' : 'default'
}
}
@@ -544,8 +608,8 @@ function onPointerUp() {
function defineNodes() {
nodes = {
'n-green': {
x: 25,
y: 350,
x: 100,
y: 400,
w: 339,
h: 409,
ox: 0,
@@ -556,8 +620,8 @@ function defineNodes() {
img: imgGreen
},
'n-red': {
x: 875,
y: 100,
x: 850,
y: 180,
w: 339,
h: 409,
ox: 0,
@@ -568,8 +632,8 @@ function defineNodes() {
img: imgRed
},
'n-blue': {
x: 875,
y: 650,
x: 850,
y: 600,
w: 339,
h: 409,
ox: 0,
@@ -580,8 +644,8 @@ function defineNodes() {
img: imgBlue
},
'n-purple': {
x: 1375,
y: 250,
x: 1380,
y: 245,
w: 595,
h: 718,
ox: 0,
@@ -592,8 +656,8 @@ function defineNodes() {
img: imgPurple
},
'n-output-ui': {
x: 425,
y: 530,
x: 460,
y: 596,
w: 386,
h: 116,
ox: 0,
@@ -632,20 +696,26 @@ function defineNodes() {
tfy: 24 / 409
},
{
id: 'canny-out',
src: 'n-red',
sfx: (339 - 24) / 339,
sfy: 24 / 409,
tgt: 'n-purple',
tfx: 42 / 595,
tfy: 64 / 718
tfy: 64 / 718,
togglable: true,
connected: true
},
{
id: 'depth-out',
src: 'n-blue',
sfx: (339 - 24) / 339,
sfy: 24 / 409,
tgt: 'n-purple',
tfx: 42 / 595,
tfy: 122 / 718
tfy: 122 / 718,
togglable: true,
connected: true
}
]
}

View File

@@ -47,6 +47,8 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
Canvas absolute-fills the section on every viewport. Because it sits on
top of the text (z-10) and is transparent except for the nodes, users
can drag nodes freely across the whole hero, over the copy included.
The default fit biases the cluster to the left on lg+ so nodes don't
start overlapping the text column on the right (see fitView).
-->
<div class="absolute inset-0 z-10">
<HeroCanvas />