516 lines
17 KiB
HTML
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>
|