project-bifrost-platform/design/preview/topographic-currents.html
2026-04-18 16:09:49 +02:00

516 lines
17 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Topographic Currents</title>
<link rel="stylesheet" href="../colors_and_type.css" />
<style>
html, body { margin: 0; background: var(--background); font-family: var(--font-sans); }
.card {
padding: 22px 24px;
box-sizing: border-box;
display: grid;
grid-template-columns: 0.95fr 1.1fr;
gap: 22px;
align-items: stretch;
height: 100%;
}
.notes { padding: 6px 0 0; display: flex; flex-direction: column; }
.kicker { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: #9a8679; }
.title { font-family: var(--font-serif); font-size: 24px; color: var(--on-surface); margin: 8px 0 10px; line-height: 1.08; letter-spacing: -0.012em; font-weight: 400; }
.title em { font-style: italic; font-weight: 700; }
.body { font-size: 13px; color: var(--on-surface-variant); line-height: 1.58; }
.body em { font-family: var(--font-serif); color: var(--on-surface); font-style: italic; }
.body + .body { margin-top: 10px; }
.rules { margin-top: 14px; display: grid; gap: 8px; }
.rules .r { display: grid; grid-template-columns: 80px 1fr; gap: 10px; font-size: 11.5px; color: var(--on-surface-variant); line-height: 1.45; }
.rules .r b { color: var(--on-surface); font-weight: 600; font-family: var(--font-sans); letter-spacing: 0.04em; font-size: 10px; text-transform: uppercase; padding-top: 2px; }
.demos {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 10px;
}
.tile {
border-radius: 12px;
padding: 10px 12px 26px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 150px;
overflow: hidden;
}
.tile.ochre { background: #c29d59; }
.tile.copper { background: #6d8c7c; }
.tile.terracotta { background: #b96b58; }
.tile.indigo { background: #5a6d83; }
.tile.ochre .lab,
.tile.copper .lab,
.tile.terracotta .lab,
.tile.indigo .lab { color: rgba(255, 252, 247, 0.82); }
.tile.ochre .tag,
.tile.copper .tag,
.tile.terracotta .tag,
.tile.indigo .tag { color: rgba(255, 252, 247, 0.72); }
.tile svg { width: 100%; height: 100%; max-height: 190px; display: block; overflow: visible; }
.tile .lab {
position: absolute; left: 12px; bottom: 8px;
font-family: var(--font-serif); font-style: italic;
font-size: 11px; color: var(--on-surface-variant);
}
.tile .tag {
position: absolute; right: 12px; top: 10px;
font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase;
color: #9a8679;
}
</style>
</head>
<body>
<div class="card">
<div class="notes">
<div class="kicker">Signature · Data & connectivity</div>
<div class="title">Topographic <em>currents</em>.</div>
<p class="body">
Parallel wavy lines running across a form, contained by its silhouette like water filling a vessel. Each region has a <em>dominant flow axis</em>; neighbouring lines undulate in the same phase with small offsets, producing a current that moves through the shape.
</p>
<p class="body">
The silhouette is a firm graphite contour. The interior is the same graphite at the same weight — but spaced so the cream ground between lines breathes as much as the ink itself.
</p>
<div class="rules">
<div class="r"><b>Flow axis</b><span>Pick one dominant direction per region. The lines run across it, not around it.</span></div>
<div class="r"><b>Phase match</b><span>Adjacent lines bend together. Coordinated waves, never independent noise.</span></div>
<div class="r"><b>Spacing</b><span>Equal cream gaps between lines. The negative space is half the drawing.</span></div>
<div class="r"><b>Termination</b><span>Lines end where the silhouette ends. They do not close.</span></div>
<div class="r"><b>Palette</b><span>Graphite silhouette + cream interior lines over any Archival Pigment ground. The pigment is the field, graphite holds the form, cream carries the current.</span></div>
</div>
</div>
<div class="demos">
<div class="tile ochre"><svg id="tile-figure" viewBox="-80 -90 160 180"></svg>
<span class="tag">Raw ochre</span>
<div class="lab">Figure · vertical current</div>
</div>
<div class="tile copper"><svg id="tile-leaf" viewBox="-80 -90 160 180"></svg>
<span class="tag">Oxidized copper</span>
<div class="lab">Leaf · grain along the blade</div>
</div>
<div class="tile terracotta"><svg id="tile-petal" viewBox="-80 -90 160 180"></svg>
<span class="tag">Faded terracotta</span>
<div class="lab">Petal · radial current</div>
</div>
<div class="tile indigo"><svg id="tile-stone" viewBox="-80 -70 160 140"></svg>
<span class="tag">Faded indigo</span>
<div class="lab">Stone · horizontal current</div>
</div>
</div>
</div>
<script>
/* =============================================================
* FLOW-FIELD STRIPING
*
* Algorithm:
* 1. Define a silhouette polygon (the vessel).
* 2. Pick a flow axis (e.g. vertical = lines run horizontally).
* 3. Generate N parallel lines spaced evenly along the axis.
* 4. For each line, sample across, applying a coherent wave
* (same frequency across all lines, slight phase drift).
* 5. Clip each line to the silhouette — keep segments inside.
* ============================================================= */
const INK = '#383831';
const CREAM = '#fffcf7';
/* ---- point-in-polygon (ray-cast) ---- */
function inside(pt, poly) {
let c = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i].x, yi = poly[i].y;
const xj = poly[j].x, yj = poly[j].y;
if (((yi > pt.y) !== (yj > pt.y)) &&
(pt.x < (xj - xi) * (pt.y - yi) / (yj - yi || 1e-9) + xi)) c = !c;
}
return c;
}
/* ---- smooth closed path for silhouette ---- */
function smoothClosedPath(pts) {
const n = pts.length;
if (n < 3) return "";
let d = `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`;
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n];
const p1 = pts[i];
const p2 = pts[(i + 1) % n];
const p3 = pts[(i + 2) % n];
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
}
return d + " Z";
}
/* ---- smooth open path ---- */
function smoothOpenPath(pts) {
const n = pts.length;
if (n < 2) return "";
if (n === 2) return `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)} L ${pts[1].x.toFixed(2)} ${pts[1].y.toFixed(2)}`;
let d = `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`;
for (let i = 0; i < n - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(n - 1, i + 2)];
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
}
return d;
}
/* ---- draw silhouette ---- */
function drawSilhouette(svg, poly, opts = {}) {
const ns = 'http://www.w3.org/2000/svg';
const p = document.createElementNS(ns, 'path');
p.setAttribute('d', smoothClosedPath(poly));
p.setAttribute('fill', opts.fill || 'none');
p.setAttribute('stroke', opts.stroke || INK);
p.setAttribute('stroke-width', opts.strokeW || 1.4);
p.setAttribute('stroke-linecap', 'round');
p.setAttribute('stroke-linejoin', 'round');
p.setAttribute('vector-effect', 'non-scaling-stroke');
svg.appendChild(p);
}
/* ---- draw a stroke ---- */
function drawStroke(svg, pts, strokeW = 1.1, color = INK) {
if (pts.length < 2) return;
const ns = 'http://www.w3.org/2000/svg';
const p = document.createElementNS(ns, 'path');
p.setAttribute('d', smoothOpenPath(pts));
p.setAttribute('fill', 'none');
p.setAttribute('stroke', color);
p.setAttribute('stroke-width', strokeW);
p.setAttribute('stroke-linecap', 'round');
p.setAttribute('stroke-linejoin', 'round');
p.setAttribute('vector-effect', 'non-scaling-stroke');
svg.appendChild(p);
}
/* ---- clip a polyline to the polygon: return continuous runs inside ---- */
function clipToPoly(pts, poly) {
const runs = [];
let cur = [];
for (const p of pts) {
if (inside(p, poly)) {
cur.push(p);
} else {
if (cur.length >= 2) runs.push(cur);
cur = [];
}
}
if (cur.length >= 2) runs.push(cur);
return runs;
}
/* ---- core: draw horizontal flow lines clipped to silhouette ---- *
* flowLines(svg, poly, {
* axis: 'horizontal' | 'vertical',
* range: [from, to], // coordinate span along the axis
* spacing: number,
* amplitude: number,
* wavelength: number,
* phaseDrift: number, // how much each successive line shifts phase
* stepsAcross: number, // samples per line
* across: [from, to], // coordinate span across the axis
* strokeW: number,
* })
*/
function flowLines(svg, poly, cfg) {
const {
axis = 'horizontal',
range, spacing, amplitude, wavelength,
phaseDrift = 0.25,
stepsAcross = 120,
across,
strokeW = 1.1,
color = INK,
ampModulate = null,
} = cfg;
const [a0, a1] = range;
const [b0, b1] = across;
let lineIdx = 0;
for (let a = a0; a <= a1; a += spacing) {
const phase = lineIdx * phaseDrift;
const pts = [];
for (let s = 0; s <= stepsAcross; s++) {
const u = s / stepsAcross;
const b = b0 + (b1 - b0) * u;
const modAmp = ampModulate ? ampModulate(u) : 1;
const wave = Math.sin((b / wavelength) * Math.PI * 2 + phase) * amplitude * modAmp;
if (axis === 'horizontal') {
// lines run horizontally, wave perturbs y
pts.push({ x: b, y: a + wave });
} else {
// lines run vertically, wave perturbs x
pts.push({ x: a + wave, y: b });
}
}
const runs = clipToPoly(pts, poly);
for (const run of runs) drawStroke(svg, run, strokeW, color);
lineIdx++;
}
}
/* =================================================================
* TILE 1 — FIGURE (torso w/ head in profile). Vertical flow axis,
* lines run horizontally across the body, undulating gently.
* ================================================================= */
{
const svg = document.getElementById('tile-figure');
// Silhouette anchors going clockwise from top of head
const outline = [
// top of head (rounded cap)
[-14,-62],[-4,-66],[8,-66],[18,-62],[24,-54],
// brow / temple
[26,-44],[26,-36],
// nose bridge + tip
[28,-30],[30,-24],[32,-18],[30,-14],
// upper lip / mouth
[26,-10],[22,-6],[24,-2],
// chin
[22, 2],[18, 6],[12, 10],
// neck
[8, 14],[6, 20],
// shoulders spreading wide
[18, 26],[28, 32],[34, 40],
// outer body silhouette (right side)
[34, 56],[32, 72],[30, 86],
// bottom (torso ends at frame)
[18, 88],[0, 88],[-18, 88],[-30, 88],
// left side going back up
[-34, 72],[-34, 56],[-32, 40],
// left shoulder
[-26, 32],[-18, 26],
// back of neck
[-14, 20],[-14, 14],
// back of head
[-16, 6],[-18, -2],[-20,-14],[-20,-28],[-18,-42],[-16,-54],
];
// resample to more points
const poly = [];
for (let i = 0; i < outline.length; i++) {
const [x1,y1] = outline[i];
const [x2,y2] = outline[(i+1) % outline.length];
for (let k = 0; k < 6; k++) {
const t = k/6;
poly.push({ x: x1+(x2-x1)*t, y: y1+(y2-y1)*t });
}
}
drawSilhouette(svg, poly, { fill: 'none', strokeW: 1.4 });
// Horizontal lines across the body (flow axis = vertical)
flowLines(svg, poly, {
axis: 'horizontal',
range: [-60, 86],
spacing: 5.5,
amplitude: 2.4,
wavelength: 26,
phaseDrift: 0.35,
stepsAcross: 80,
across: [-38, 38],
strokeW: 1.0,
color: CREAM,
// taper waves near the edges of the shape
ampModulate: u => {
// bump up slightly in the middle, ease at edges
return 0.6 + 0.4 * Math.sin(u * Math.PI);
}
});
// Eye mark (small graphite detail)
const ns = 'http://www.w3.org/2000/svg';
const eye = document.createElementNS(ns, 'path');
eye.setAttribute('d', 'M 20 -30 L 22 -30');
eye.setAttribute('stroke', CREAM);
eye.setAttribute('stroke-width', 1.3);
eye.setAttribute('stroke-linecap', 'round');
eye.setAttribute('vector-effect', 'non-scaling-stroke');
svg.appendChild(eye);
}
/* =================================================================
* TILE 2 — LEAF (almond, grain runs along the blade length)
* Vertical flow axis, horizontal lines, but tapered.
* ================================================================= */
{
const svg = document.getElementById('tile-leaf');
// Parametric almond leaf pointed top & bottom
const n = 120;
const poly = [];
for (let i = 0; i < n; i++) {
const t = (i / n) * Math.PI * 2;
const a = 34;
const b = 64;
// pointed almond: narrower at both ends
const shapeY = -Math.cos(t) * b;
const taper = Math.pow(Math.sin((shapeY + b) / (2*b) * Math.PI), 0.8); // 0 at tips, 1 at center
const x = a * Math.sin(t) * taper;
poly.push({ x, y: shapeY });
}
drawSilhouette(svg, poly, { fill: 'none', strokeW: 1.4 });
// Midrib
drawStroke(svg, [
{ x: 0, y: -60 }, { x: 0, y: -30 }, { x: 0, y: 0 }, { x: 0, y: 30 }, { x: 0, y: 60 }
], 1.3, CREAM);
// Flow lines running horizontally, slightly v-shaped toward midrib to suggest venation.
// Split into left half + right half so each slants toward the midrib.
// LEFT half: lines go from left edge toward the midrib, angled up
// RIGHT half: lines go from midrib to right edge, angled up
// We'll do it by generating two offset waves, clipped by the leaf polygon.
const linesPerSide = 22;
for (let i = 0; i < linesPerSide; i++) {
const y0 = -58 + i * (116 / linesPerSide);
// RIGHT side: line goes from (0, y0) to (35, y0 - 8) — angled slightly up
{
const pts = [];
const steps = 40;
for (let s = 0; s <= steps; s++) {
const u = s / steps;
const x = u * 40;
const y = y0 - u * 6 + Math.sin(u * Math.PI * 2.5 + i * 0.4) * 1.6;
pts.push({ x, y });
}
const runs = clipToPoly(pts, poly);
for (const r of runs) drawStroke(svg, r, 1.0, CREAM);
}
// LEFT side: mirror
{
const pts = [];
const steps = 40;
for (let s = 0; s <= steps; s++) {
const u = s / steps;
const x = -u * 40;
const y = y0 - u * 6 + Math.sin(u * Math.PI * 2.5 + i * 0.4 + 0.5) * 1.6;
pts.push({ x, y });
}
const runs = clipToPoly(pts, poly);
for (const r of runs) drawStroke(svg, r, 1.0, CREAM);
}
}
}
/* =================================================================
* TILE 3 — PETAL (tulip-petal from the reference, radial current)
* ================================================================= */
{
const svg = document.getElementById('tile-petal');
// Build a rounded petal silhouette, pointed at top, rounded at bottom.
const n = 140;
const poly = [];
for (let i = 0; i < n; i++) {
const t = (i / n) * Math.PI * 2;
// base ellipse wider at bottom
let x = Math.sin(t) * 44;
let y = -Math.cos(t) * 58;
// sharpen top (y<0)
if (y < -10) {
const k = Math.min(1, (-y - 10) / 48);
const taper = 1 - 0.7 * Math.pow(k, 1.3);
x *= taper;
}
// widen bottom slightly
if (y > 20) {
const k = (y - 20) / 38;
x *= 1 + 0.08 * k;
}
poly.push({ x, y });
}
drawSilhouette(svg, poly, { fill: 'none', strokeW: 1.4 });
// Horizontal wavy lines — vertical flow axis, but with a stronger wave
flowLines(svg, poly, {
axis: 'horizontal',
range: [-58, 60],
spacing: 5,
amplitude: 3.5,
wavelength: 22,
phaseDrift: 0.5,
stepsAcross: 90,
across: [-48, 48],
strokeW: 1.0,
color: CREAM,
ampModulate: u => 0.5 + 0.5 * Math.sin(u * Math.PI),
});
}
/* =================================================================
* TILE 4 — STONE (irregular pebble, horizontal current)
* ================================================================= */
{
const svg = document.getElementById('tile-stone');
function mulberry(seed) {
return function() {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
const rand = mulberry(19);
const n = 180;
const poly = [];
const jitter = [];
for (let k = 0; k < 14; k++) jitter.push(0.85 + rand() * 0.25);
for (let i = 0; i < n; i++) {
const t = (i / n) * Math.PI * 2;
const idx = (i / n) * jitter.length;
const i0 = Math.floor(idx) % jitter.length;
const i1 = (i0 + 1) % jitter.length;
const f = idx - Math.floor(idx);
const mul = jitter[i0] * (1 - f) + jitter[i1] * f;
poly.push({ x: Math.cos(t) * 62 * mul, y: Math.sin(t) * 42 * mul });
}
drawSilhouette(svg, poly, { fill: 'none', strokeW: 1.4 });
flowLines(svg, poly, {
axis: 'horizontal',
range: [-42, 42],
spacing: 4.5,
amplitude: 2.2,
wavelength: 28,
phaseDrift: 0.4,
stepsAcross: 90,
across: [-70, 70],
strokeW: 1.0,
color: CREAM,
ampModulate: u => 0.5 + 0.5 * Math.sin(u * Math.PI),
});
}
</script>
</body>
</html>