From 399540fc18f8f80b1f3d3ca34491c11b1f0b292e Mon Sep 17 00:00:00 2001 From: Nick Walker Date: Thu, 25 Dec 2025 17:28:18 -0700 Subject: [PATCH 1/7] Add DM26 page Add initial hero toy --- js/LonelyRunnerToy.js | 308 ++++++ pages/drumheller-marathon-26.html | 1516 +++++++++++++++++++++++++++++ 2 files changed, 1824 insertions(+) create mode 100644 js/LonelyRunnerToy.js create mode 100644 pages/drumheller-marathon-26.html diff --git a/js/LonelyRunnerToy.js b/js/LonelyRunnerToy.js new file mode 100644 index 0000000..341450f --- /dev/null +++ b/js/LonelyRunnerToy.js @@ -0,0 +1,308 @@ +import {DEFAULT_VERTEX_SHADER} from "./shader_utils.js"; + +// --- Configuration Constants --- +const RING_THICKNESS_PX = 10; +const RING_SPACING_PX = 20; +const INNER_RADIUS_PX = 200; +const NUM_RINGS = 15; +const USE_DISTINCT_COLORS = false; // Toggle for color vs B&W mode +const HIGHLIGHT_LONELY = true; // Toggle opacity based on loneliness + +// Generate colors (HSL to RGB conversion helper or just simple generation) +function hslToRgb(h, s, l) { + let r, g, b; + if (s === 0) { + r = g = b = l; // achromatic + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return [r, g, b]; +} + +function generateRingConfigs(numRings) { + const configs = []; + // Start with 3 runners on the inner ring? + // Or just distinct velocities for each ring? + // Let's assume standard Lonely Runner setup: N runners with velocities 1..N + // But we have multiple rings. + // Let's give Ring i -> (i + 3) runners with velocities 1..(i+3) + // Colors generated rainbow-like across the rings. + + for (let i = 0; i < numRings; i++) { + const numRunners = i + 3; + const velocities = []; + const colors = []; + + for (let j = 0; j < numRunners; j++) { + // Velocities: 1, 2, 3 ... + velocities.push(j + 1.0); + + // Color: Vary Hue based on velocity index and ring index + // Global rainbow? + const hue = (j / numRunners + i / numRings) % 1.0; + const rgb = hslToRgb(hue, 0.7, 0.6); + colors.push(rgb); + } + + configs.push({ + velocities: velocities, + colors: colors + }); + } + return configs; +} + +const RING_CONFIGS = generateRingConfigs(NUM_RINGS); + +function createRunnerRingFragmentShader(ringConfigs) { + + // Generate uniform declarations + let uniforms = ``; + uniforms += `uniform vec3 u_geom_params;\n`; // x: thickness_norm, y: spacing_norm, z: inner_radius_norm + uniforms += `uniform bool u_use_colors;\n`; + uniforms += `uniform bool u_highlight_lonely;\n`; + + for (let i = 0; i < ringConfigs.length; i++) { + const n = ringConfigs[i].velocities.length; + // Optimization: Pass pre-computed positions and buffer sizes + uniforms += `uniform vec3 colors_${i}[${n}];\n`; + uniforms += `uniform float positions_${i}[${n}];\n`; + uniforms += `uniform float loneliness_${i}[${n}];\n`; + } + + // Generate the logic for each ring + let ringLogic = ``; + + // We build the rings dynamically in the shader using the uniforms + // Ring i radius = inner + i * spacing + + for (let i = 0; i < ringConfigs.length; i++) { + const n = ringConfigs[i].velocities.length; + + // Logic block for Ring i + ringLogic += ` + { + // --- RING ${i} --- + // Calculate geometry in UV space based on uniforms + float trackWidth = u_geom_params.x; + float spacing = u_geom_params.y; + float innerRadius = u_geom_params.z; + + float trackRadius = innerRadius + float(${i}) * spacing; + float trackHalfWidth = trackWidth * 0.5; + + float distToTrackCenter = abs(dist - trackRadius); + + // Optimization: Only compute precise SDF if we are somewhat close to the track + // Relaxed check to allow for corner radius overflow slightly + if (distToTrackCenter < trackHalfWidth + 0.02) { // 0.02 is arbitrary margin + + vec3 finalRingColor = vec3(0.0); // Base track color + float minSd = 1000.0; + vec3 nearestRunnerColor = vec3(0.0); + float nearestLoneliness = 0.0; + + // Check all runners to find the closest segment shape + for (int j = 0; j < ${n}; j++) { + // Polar-ish coordinates + // X: Arc length along the ring relative to runner center + float distArc = angularDist(angle, positions_${i}[j]) * trackRadius; + // Y: Radial distance from center of ring + float distRadial = distToTrackCenter; + + // Capsule (Segment) Logic + float halfArcLength = loneliness_${i}[j] * trackRadius; + + // Add a small buffer/gap between segments so they don't touch + float gap = 0.005; + + // The "straight" segment length is the total length minus the rounded caps and the gap. + // Each cap has radius 'trackHalfWidth'. + float segLen = max(0.0, halfArcLength - trackHalfWidth - gap); + + // SDF for a 2D segment on the X-axis from -segLen to +segLen + // with radius 'trackHalfWidth'. + vec2 pSeg = vec2(max(abs(distArc) - segLen, 0.0), distRadial); + float sd = length(pSeg) - trackHalfWidth; + + if (sd < minSd) { + minSd = sd; + nearestRunnerColor = u_use_colors ? colors_${i}[j] : vec3(1.0); + nearestLoneliness = loneliness_${i}[j]; + } + } + + // Blend Runner on top of Track + // smoothstep gives a nice anti-aliased edge (pixel width approx) + // We use fwidth for AA? Or fixed constant. Fixed is safer for now. + float aaWidth = 0.002; + float runnerAlpha = 1.0 - smoothstep(0.0, aaWidth, minSd); + + // Highlight lonely runners + float opacity = 1.0; + if (u_highlight_lonely) { + // Map loneliness (angular half-distance) to opacity + // Non-linear ramp to make "lonely" state pop massively + // loneliness is radians. + // 0.05 rad is crowded. 0.5 rad is lonely. + float normL = smoothstep(0.05, 0.5, nearestLoneliness); + // Power curve to compress the low end and pop the high end + opacity = 0.1 + 0.9 * pow(normL, 8.0); + } + + finalRingColor = mix(finalRingColor, nearestRunnerColor, runnerAlpha * opacity); + + // Output alpha + //float trackAlpha = 1.0 - smoothstep(trackHalfWidth - aaWidth, trackHalfWidth, distToTrackCenter); + float trackAlpha = 0.0; + float finalAlpha = max(trackAlpha, runnerAlpha * opacity * 0.9); + + outColor = vec4(finalRingColor, finalAlpha); + return; + } + } + `; + } + + + const frag = /* glsl */ `#version 300 es + precision mediump float; + + uniform vec2 resolution; + uniform float time; + + ${uniforms} + + out vec4 outColor; + + const float PI = 3.14159265359; + const float TAU = 6.28318530718; + + // Helper to get angular distance between two angles in [0, TAU] + float angularDist(float a1, float a2) { + float diff = a1 - a2; + // Robust modular distance + return abs(mod(diff + PI, TAU) - PI); + } + + void main() { + // Normalize coordinates to [-1, 1], correcting for aspect ratio + vec2 uv = (gl_FragCoord.xy * 2.0 - resolution.xy) / min(resolution.x, resolution.y); + + // Convert to polar coordinates + float dist = length(uv); + float angle = atan(uv.y, uv.x); // [-PI, PI] + if (angle < 0.0) angle += TAU; // [0, TAU] + + // Default Background + outColor = vec4(0.0, 0.0, 0.0, 0.0); + + // Generated Ring Logic + ${ringLogic} + } + `; + return { shader: frag }; +} + +export let LonelyRunnerToy = rootUrl => p => { + let width; + let height; + let runnerShader; + let startStamp; + + p.setup = async () => { + startStamp = Date.now() / 1000.0; + + width = p._userNode.offsetWidth; + height = p._userNode.offsetHeight; + + // Initialize canvas + p.createCanvas(width, height, p.WEBGL); + p.imageMode(p.CENTER); + p.rectMode(p.CENTER); // Ensure quads are drawn from center + p.noStroke(); + + // Create Shader + const shaderData = createRunnerRingFragmentShader(RING_CONFIGS); + runnerShader = p.createShader(DEFAULT_VERTEX_SHADER, shaderData.shader); + p.shader(runnerShader); + + // Set static uniforms for each ring (colors) + RING_CONFIGS.forEach((config, index) => { + // Velocities not needed in shader anymore + runnerShader.setUniform(`colors_${index}`, config.colors.flat()); + }); + } + + p.draw = function () { + p.clear(); + + // Calculate geometry parameters in Normalized Device Coordinates (UV space) + // UV space goes from -1 to 1 along the shortest dimension. + // So 1 unit = min(width, height) / 2 pixels. + const minDim = Math.min(width, height); + const uvScale = 2.0 / minDim; // 1 pixel = uvScale units + + const thicknessNorm = RING_THICKNESS_PX * uvScale; + const spacingNorm = RING_SPACING_PX * uvScale; + const innerRadiusNorm = INNER_RADIUS_PX * uvScale; + + // Pass dynamic uniforms + const time = (Date.now() / 10000.0 - startStamp); + runnerShader.setUniform("time", time); + // Fix for high-DPI displays: pass physical resolution + runnerShader.setUniform("resolution", [width * p.pixelDensity(), height * p.pixelDensity()]); + + // Pass geometry params + runnerShader.setUniform("u_geom_params", [thicknessNorm, spacingNorm, innerRadiusNorm]); + runnerShader.setUniform("u_use_colors", USE_DISTINCT_COLORS); + runnerShader.setUniform("u_highlight_lonely", HIGHLIGHT_LONELY); + + const TAU = Math.PI * 2; + + RING_CONFIGS.forEach((config, i) => { + // 1. Calculate all positions for this ring + const currentPositions = config.velocities.map(v => (time * v * 0.5) % TAU); + + // 2. Calculate loneliness (buffer size) for each runner + const loneliness = currentPositions.map((pos1, j) => { + let minDist = TAU; + currentPositions.forEach((pos2, k) => { + if (j === k) return; + let d = Math.abs(pos1 - pos2); + // Handle wrap-around distance + let dist = Math.min(d, TAU - d); + if (dist < minDist) minDist = dist; + }); + // Territory is half the distance to the nearest neighbor + return minDist * 0.5; + }); + + // 3. Update uniforms + // Use Float32Array for safe WebGL uniform passing + runnerShader.setUniform(`positions_${i}`, new Float32Array(currentPositions)); + runnerShader.setUniform(`loneliness_${i}`, new Float32Array(loneliness)); + }); + + // Draw a rect covering the screen to run the shader + p.rect(0, 0, width, height); + } + + p.windowResized = function () { + width = p._userNode.offsetWidth; + height = p._userNode.offsetHeight; + p.resizeCanvas(width, height, true); + } +} diff --git a/pages/drumheller-marathon-26.html b/pages/drumheller-marathon-26.html new file mode 100644 index 0000000..380ea28 --- /dev/null +++ b/pages/drumheller-marathon-26.html @@ -0,0 +1,1516 @@ +--- +title: Drumheller Marathon 2026 +description: Marathon at the University of Washington's iconic Drumheller Fountain on June 6th, 2026. Choose from 219 laps for the marathon or 109 laps for the half +permalink: drumheller-marathon-26/ +redirect_from: +- /dhm/ +- /drumheller-half/ +- /dm/ +- /drumheller-marathon/ +- /dm26/ +registration_link: https://forms.gle/uEAhyLzrcbKWSaRv7 +shirt_order_link: https://forms.gle/GroYX6gFR88b4rqb6 +--- + + + + + {{ page.title }} + + + + + + + + + + + + + + + + + + + + + {% include analytics.html %} + + + + + +
+
+ +
+
+
+ + + +
+
The Race Condition Running

Drumheller Marathon

+
+
+
+ + +
+ +
June 6th, 2026
+ +
+ Apply for Full +
5:30AM
+
+
+ Register for Half +
8:00AM
+
+ + + + + + + +
+ +
+
+
+ + +
+
+

Course

+

The course consists of a short segment down the upper vista followed by laps around Drumheller Fountain and the duck ramp . The marathon comprises 219 laps and the half marathon consists of 109 laps. Both events will start at different times and locations. Participants aiming for fewer laps should start with the half marathoners.

+ +

The + direction will flip periodically, and you must change directions only by rounding the marker placed + at the edge of the finish line. We cannot provide you an official time unless you follow these instructions and complete the necessary number of laps.

+ +

While campus activity is reduced on weekends, expect to navigate around fountain-admirers, passers-by and + cyclists. The course is not closed.

+ +

Etiquette

+ +
    +
  • Run in the inner lane along the curb
  • +
  • Do not run more than two wide
  • +
  • Pass on the outside unless the other runner yields
  • +
  • Walk or stop along the outside of the loop only
  • +
+ + + +
+ +
+ +
+
+

Travel

+

The course is served by the University of Washington light rail station. Many buses stop at UW or the nearby U District station.

+

Central Plaza Garage (CPG) beneath Red Square is the closest parking. See the University's page for rates and instructions. Check the parking calendar for any disruptions and leave yourself extra time for getting lost and paying.

+
+
+ +
+
+

Race Day

+
    +
  • Bring a water bottle
  • +
  • Be at the start line 10 minutes early for course instructions and a photo
  • +
+

Schedule

+
    +
  • 5:30AM: Full start ↻
  • +
  • 6:00AM: Early Half start (target 7:40/mi or 4:45/km pace) ↺
  • +
  • 8:00AM: Half start ↺
  • +
  • 20 minutes after last Full finisher: Full awards and photo
  • +
  • 11:00AM: Course closes, group photo
  • +
+ + +

Marathon Start List

+ Updated 5/30/25 + + + + + + + + + + + + + +
NameGoal
Emma Favier 4:00
Benjamin Han 3:30
Ethan Ancell 4:00
Margaret Li 3:45
Zachary Tatlock 4:00
Julien Luebbers 3:00
Dylan Hull 3:00
+ +

Emails

+ +

Everything important is above, but participants should have also received the following by email:

+ + + Live tracking and safety - 6/02/25 + +

Hello Drumheller runners,

+ +

The schedule is posted and our FAQ has never been more comprehensive. Please take a read. Two safety related items:

+ +

1. If you are running faster than 7:40/mi (4:45/km) pace over your chosen distance, let me know and plan to start in the early half marathon section. You'll have an easier time and we'll avoid collisions.

+ +

2. Bring a cup/bottle. We'll have water tanks out so you can refill, but no vessels.

+ +

We are again providing a phone-based lap counting service. The attached QR code is your unique live tracking credential, which is also available with instructions at your private link. There is some setup required, so take a look at your page and the overview on the site. Use it as a backup, and test before the race.

+ +

[unique participant QR code]

+ +

Lastly, if you won't be running, please fill out the registration with the same account and mark withdraw. Otherwise, you'll keep getting emails.

+ +

Forecast is looking good for mountain-gazing, so keep your fingers crossed.

+ +

--Nick Walker

+ +
+
+ +
+
+ +
+ +
+
+

Results

+ + +

Marathon

+
+ +

Half Marathon

+
+
+

Unofficial

+
+
+ +
+ +
+ +
+

FAQ

+ + + + What's the start time? + +

Different distances will start at different times between 5-10AM. Participants running the full or a faster half will run earlier. The schedule will be posted in mid-April based on registrations.

+
+
+ + Do I have to run the whole thing? + +

No. Anyone running or walking fewer than 109 laps is welcome to join for the last start time. Here are some lap counts for common distances. Add a lap if not starting at the official start line.

+ + + + + + + +
DistanceLaps
5k 26
10k 52
10mi83
+ +

Excepting pacers, full marathon participants must intend to complete the full distance.

+
+
+ + How fast do I need to run? +

The cutoff times and corresponding average paces are:

+ + + + + + +
DistanceTimemin/mi min/km
Marathon 4:30:00 10:18 6:24
Half Marathon 3:00:00 13:43 8:31
+
+
+ + + What's the weather like? + + +
On June 7, the temperature in Seattle typically ranges from 54°F to 67°F and is rarely below 49°F or above 77°F. + [...] + The coolest time of the day is from 1:00 AM to 8:15 AM, with the coldest at 5:30 AM, at which time the temperature is below 56°F three days out of four, and below 58°F nine days out of ten. + [...] + The cloudiest time of day is around 6:30 AM, at which time the chance of overcast or mostly cloudy conditions is 57%. +
+ +
+
+ + + Is there gear check? +

You can stash a bag near the aid station. You'll have line of sight the whole race, but they won't be attended.

+
+
+ + + How do we keep track of distance? +

Use a running watch to mark laps if you're wearing one, though you should check that it can handle 100+ laps. GPS will clip the curve, so don't rely on it for more than a couple + laps at a + time. + We'll have tally counters available if + you + prefer an analog counting aide.

+ +

Our experimental phone-based lap-counting service will also be available for all registered runners. We recommended using it as a backup for another method of counting.

+ +

Counting without assistance is not recommended.

+
+
+ + + Which direction do we run? + +

The race will begin counterclockwise and alternate directions every 30 minutes to help balance the strain across your legs. Change directions only at the end of a lap and only by rounding the finish line marker.

+
+
+ + + How is the event being timed? + +

Official times will be determined using a recorded video. It is critical that you run at least the required number of laps so we can provide you an official time.

+

Finalizing the results can take weeks. Please use the live tracking system so you'll have an unofficial time immediately, and to make it easier for us to mark times.

+
+
+ + + Why do I have to apply for the marathon? + +

We need information about your plans and preparedness in order to assess whether we can accommodate you. The spread of goal times determines our maximum field size and start times. Later applications will be judged for compatibility with the existing field. You will be contacted to discuss compromises if we cannot immediately accept your application.

+

The event has a minimum field size of 3. It is extremely unlikely, but should we have fewer than 3 registrations on April 1st, we will notify registered runners to make alternative plans.

+ +
+
+ + + Is it okay if I've never run a full marathon before? + +

Maybe. The answer will depend on your background, so please discuss with the organizers first if you would like to plan on Drumheller being your first marathon. Note that we require evidence of a half marathon time commensurate with your marathon goal time.

+ +
+
+ + + Are pacers allowed? + +

Yes. Runners intending to use pacers must submit a pacing plan with their application, and pacers must also register. Only a single pacer per time goal will be allowed on the course at a time, so you may be asked to coordinate with other runners. Unlike most road races, we allow rotating/non-starting pacers, however official times will only be given to participants who run the complete course.

+
+
+ + + + Can I take breaks? + +

Yes. In addition to the aid station there are benches surrounding the course. You must continue running the lap in the same direction as when you stopped or the lap will not count. We recommend you start and stop only on at the lap finish line so you can begin the next lap in either direction.

+
+
+ + + + + Is this a Boston Qualifying race? + +

Yes, the full marathon will meet the B.A.A. criteria for a qualifying race (see Rules and Policies pp.5-6 §2.2.1).

+
+
+ + + Isn't running a continuous curve going to slow me down? + +

Yes, but not substantially. You will need to supply an additional centripetal force due to the curve, but the effect of this is likely less than 1% when running at world record marathon pace on a 30m radius. Because the force increases in the square of velocity, the effect is negligible for recreational runners. There are individual differences in running technique and physiology that may make the effect more or less pronounced for you. Consider practicing on the course or a track.

+
+
+ + + Will running a continuous curve for dozens of laps injure me? + +

Unlikely. Many athletes train and race on smaller radius tracks without issue during indoor track season. There is evidence that consistently running the same direction on small indoor tracks over time leads to strength asymmetries which likely cause injuries. Alternating directions is probably sufficient to avoid acute issues for most runners.

+ +

You may also take comfort from knowing that indoor track marathons have been held for years without problems.

+
+
+ + + What if I'm late? +

Run from the start line and join us! You must enter the course from "behind" the finish line in the direction that the field is running. So, if we're running counterclockwise, start your first loop by crossing the line from the east, and otherwise from the west. Note that this event is gun time only.

+ +
+
+ + I have a different question. + +

Ask over email.

+
+
+
+
+
+ + +
+ + +
+
Davis is replacing one of twelve 500-watt bulbs that ring the center of the fountain. He eases the large bulb into a solid brass housing. “Many of the lights had gone out since the last maintenance,” Davis explains. “Over time, water gets into the housings, turns to steam, and eventually the bulb goes boom!” +
+ +
+ +
+ + + + + + + + + From 0f2f70d513a1d59f50e1fdc91f841286656bec26 Mon Sep 17 00:00:00 2001 From: Nick Walker Date: Sat, 27 Dec 2025 16:23:43 -0700 Subject: [PATCH 2/7] Add audio and interaction to DM26 toy --- js/LonelyRunnerToy.js | 711 +++++++++++++++++++++++++----- pages/drumheller-marathon-26.html | 132 ++---- 2 files changed, 646 insertions(+), 197 deletions(-) diff --git a/js/LonelyRunnerToy.js b/js/LonelyRunnerToy.js index 341450f..6f059da 100644 --- a/js/LonelyRunnerToy.js +++ b/js/LonelyRunnerToy.js @@ -4,61 +4,66 @@ import {DEFAULT_VERTEX_SHADER} from "./shader_utils.js"; const RING_THICKNESS_PX = 10; const RING_SPACING_PX = 20; const INNER_RADIUS_PX = 200; -const NUM_RINGS = 15; -const USE_DISTINCT_COLORS = false; // Toggle for color vs B&W mode -const HIGHLIGHT_LONELY = true; // Toggle opacity based on loneliness - -// Generate colors (HSL to RGB conversion helper or just simple generation) -function hslToRgb(h, s, l) { - let r, g, b; - if (s === 0) { - r = g = b = l; // achromatic - } else { - const hue2rgb = (p, q, t) => { - if (t < 0) t += 1; - if (t > 1) t -= 1; - if (t < 1 / 6) return p + (q - p) * 6 * t; - if (t < 1 / 2) return q; - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; - return p; - }; - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - r = hue2rgb(p, q, h + 1 / 3); - g = hue2rgb(p, q, h); - b = hue2rgb(p, q, h - 1 / 3); - } - return [r, g, b]; -} +const NUM_RINGS = 20; +const ANIMATION_SPEED = 0.1; // Global speed multiplier (lower = slower) +const SHOW_CENTER_DOTS = false; + +// Audio configuration +const DRONE_BASE_FREQ = 92.50; // Gb2 +// G-flat major scale +// Gb, Ab, Bb, Cb, Db, Eb, F +const DRONE_SCALE = [1, 9/8, 5/4, 4/3, 3/2, 5/3, 15/8]; // Just intonation ratios + function generateRingConfigs(numRings) { const configs = []; - // Start with 3 runners on the inner ring? - // Or just distinct velocities for each ring? - // Let's assume standard Lonely Runner setup: N runners with velocities 1..N - // But we have multiple rings. - // Let's give Ring i -> (i + 3) runners with velocities 1..(i+3) - // Colors generated rainbow-like across the rings. - + const innerRadius = INNER_RADIUS_PX; + for (let i = 0; i < numRings; i++) { const numRunners = i + 3; const velocities = []; const colors = []; - + + // Calculate this ring's radius + const ringRadius = innerRadius + i * RING_SPACING_PX; + + // Scale factor to maintain constant apparent speed across rings + // Outer rings move slower in angular velocity to match arc speed + const radiusScale = innerRadius / ringRadius; + + // Create array of half branding-color-light and half branding-color-complement-light + const brandingColorLight = 0xAEDCE6; // #aedce6 + const brandingColorComplementLight = 0xDFACE6; // #dface6 + + // Create array with half of each color + const colorPool = []; + for (let j = 0; j < Math.ceil(numRunners / 2); j++) { + colorPool.push(brandingColorLight); + } + for (let j = 0; j < Math.floor(numRunners / 2); j++) { + colorPool.push(brandingColorComplementLight); + } + // Shuffle the color pool + for (let j = colorPool.length - 1; j > 0; j--) { + const k = Math.floor(Math.random() * (j + 1)); + [colorPool[j], colorPool[k]] = [colorPool[k], colorPool[j]]; + } + for (let j = 0; j < numRunners; j++) { - // Velocities: 1, 2, 3 ... - velocities.push(j + 1.0); - - // Color: Vary Hue based on velocity index and ring index - // Global rainbow? - const hue = (j / numRunners + i / numRings) % 1.0; - const rgb = hslToRgb(hue, 0.7, 0.6); - colors.push(rgb); + // Random base velocity between 0.5 and 3.0 + const baseVelocity = 0.5 + Math.random() * 2.5; + + // Scale by radius to maintain constant apparent speed + velocities.push(baseVelocity * radiusScale); + + // Assign color from shuffled pool + colors.push(colorPool[j]); } - + configs.push({ velocities: velocities, - colors: colors + colors: colors, + lonelyThreshold: Math.PI / numRunners }); } return configs; @@ -71,15 +76,16 @@ function createRunnerRingFragmentShader(ringConfigs) { // Generate uniform declarations let uniforms = ``; uniforms += `uniform vec3 u_geom_params;\n`; // x: thickness_norm, y: spacing_norm, z: inner_radius_norm - uniforms += `uniform bool u_use_colors;\n`; - uniforms += `uniform bool u_highlight_lonely;\n`; + uniforms += `uniform bool u_show_center_dots;\n`; + uniforms += `uniform sampler2D u_color_texture;\n`; + uniforms += `uniform vec2 u_texture_size;\n`; for (let i = 0; i < ringConfigs.length; i++) { const n = ringConfigs[i].velocities.length; - // Optimization: Pass pre-computed positions and buffer sizes - uniforms += `uniform vec3 colors_${i}[${n}];\n`; - uniforms += `uniform float positions_${i}[${n}];\n`; + uniforms += `uniform float positions_${i}[${n}];\n`; uniforms += `uniform float loneliness_${i}[${n}];\n`; + uniforms += `uniform int hasBeenLonely_${i}[${n}];\n`; // Track if runner has ever been lonely (0 or 1) + uniforms += `uniform float lonelyThreshold_${i};\n`; // TAU/(2*N) for this ring } // Generate the logic for each ring @@ -111,36 +117,40 @@ function createRunnerRingFragmentShader(ringConfigs) { vec3 finalRingColor = vec3(0.0); // Base track color float minSd = 1000.0; - vec3 nearestRunnerColor = vec3(0.0); float nearestLoneliness = 0.0; - + + // Track which runner is nearest and whether it has been lonely + int nearestRunnerIndex = 0; + int nearestRunnerHasBeenLonely = 0; + // Check all runners to find the closest segment shape for (int j = 0; j < ${n}; j++) { // Polar-ish coordinates // X: Arc length along the ring relative to runner center float distArc = angularDist(angle, positions_${i}[j]) * trackRadius; // Y: Radial distance from center of ring - float distRadial = distToTrackCenter; - + float distRadial = distToTrackCenter; + // Capsule (Segment) Logic float halfArcLength = loneliness_${i}[j] * trackRadius; - + // Add a small buffer/gap between segments so they don't touch - float gap = 0.005; + float gap = 0.005; // The "straight" segment length is the total length minus the rounded caps and the gap. // Each cap has radius 'trackHalfWidth'. float segLen = max(0.0, halfArcLength - trackHalfWidth - gap); - + // SDF for a 2D segment on the X-axis from -segLen to +segLen // with radius 'trackHalfWidth'. vec2 pSeg = vec2(max(abs(distArc) - segLen, 0.0), distRadial); float sd = length(pSeg) - trackHalfWidth; - + if (sd < minSd) { minSd = sd; - nearestRunnerColor = u_use_colors ? colors_${i}[j] : vec3(1.0); nearestLoneliness = loneliness_${i}[j]; + nearestRunnerIndex = j; + nearestRunnerHasBeenLonely = hasBeenLonely_${i}[j]; } } @@ -150,25 +160,66 @@ function createRunnerRingFragmentShader(ringConfigs) { float aaWidth = 0.002; float runnerAlpha = 1.0 - smoothstep(0.0, aaWidth, minSd); - // Highlight lonely runners + // Highlight lonely runners with smooth color transition float opacity = 1.0; - if (u_highlight_lonely) { - // Map loneliness (angular half-distance) to opacity - // Non-linear ramp to make "lonely" state pop massively - // loneliness is radians. - // 0.05 rad is crowded. 0.5 rad is lonely. - float normL = smoothstep(0.05, 0.5, nearestLoneliness); - // Power curve to compress the low end and pop the high end - opacity = 0.1 + 0.9 * pow(normL, 8.0); - } - - finalRingColor = mix(finalRingColor, nearestRunnerColor, runnerAlpha * opacity); + vec2 texCoord = vec2( + (float(nearestRunnerIndex) + 0.5) / u_texture_size.x, + (float(${i}) + 0.5) / u_texture_size.y + ); + vec3 targetColor = texture(u_color_texture, texCoord).rgb; + vec3 whiteColor = vec3(1.0); + vec3 finalRunnerColor = whiteColor; + + // Only fade in very close to the actual lonely threshold + // Start at 95% of threshold, full brightness at threshold + float fadeStart = lonelyThreshold_${i} * 0.95; + float fadeEnd = lonelyThreshold_${i}; + float normL = smoothstep(fadeStart, fadeEnd, nearestLoneliness); + + // Opacity transition + opacity = 0.1 + 0.9 * pow(normL, 2.0); + + // Smooth color transition from white to assigned color + // Base color depends on whether runner has been lonely before + vec3 baseColor = (nearestRunnerHasBeenLonely != 0) ? targetColor : whiteColor; + finalRunnerColor = mix(baseColor, targetColor, normL); + + finalRingColor = mix(finalRingColor, finalRunnerColor, runnerAlpha * opacity); + // Output alpha //float trackAlpha = 1.0 - smoothstep(trackHalfWidth - aaWidth, trackHalfWidth, distToTrackCenter); float trackAlpha = 0.0; float finalAlpha = max(trackAlpha, runnerAlpha * opacity * 0.9); - + + // Draw center dots if enabled + if (u_show_center_dots) { + for (int j = 0; j < ${n}; j++) { + // Calculate position of this runner's center on the ring + float runnerAngle = positions_${i}[j]; + + // Convert to cartesian coordinates for the dot center + vec2 dotCenter = vec2(cos(runnerAngle), sin(runnerAngle)) * trackRadius; + + // Distance from current pixel to dot center + float dotDist = length(uv - dotCenter); + + // Dot has radius = trackHalfWidth + float dotRadius = trackHalfWidth; + float dotSd = dotDist - dotRadius; + + // Render dot with anti-aliasing + float dotAlpha = 1.0 - smoothstep(0.0, aaWidth, dotSd); + + if (dotAlpha > 0.0) { + // Black dot color + vec3 dotColor = vec3(0.0); + finalRingColor = mix(finalRingColor, dotColor, dotAlpha); + finalAlpha = max(finalAlpha, dotAlpha * 0.9); + } + } + } + outColor = vec4(finalRingColor, finalAlpha); return; } @@ -221,82 +272,544 @@ export let LonelyRunnerToy = rootUrl => p => { let height; let runnerShader; let startStamp; + let colorTexture; + + const TAU = Math.PI * 2; + + // Interaction state + let draggingRunner = null; + let lastMouseAngle = 0; + + // Track which runners have been lonely at least once + let hasBeenLonely = []; // Array of arrays (per ring, per runner) + // Track manual position offsets for runners + let runnerOffsets = []; // Array of arrays (per ring, per runner) + + // UI elements + let lonelyCountEl = null; + let totalRunnersEl = null; + let lonelyCounterContainer = null; + let muteButtonEl = null; + let isMuted = true; + let hasUserInteractedWithCanvas = false; + + function toggleMute(targetState) { + if (typeof targetState !== 'undefined') { + isMuted = targetState; + } else { + isMuted = !isMuted; + } + + if (masterGain) { + const now = audioContext.currentTime; + masterGain.gain.cancelScheduledValues(now); + masterGain.gain.setValueAtTime(masterGain.gain.value, now); + // Ramp to a very small value instead of 0 to avoid issues with exponential ramps elsewhere if any + masterGain.gain.linearRampToValueAtTime(isMuted ? 0.0001 : 1.0, now + 0.1); + } + if (muteButtonEl) { + muteButtonEl.textContent = isMuted ? 'Unmute' : 'Mute'; + } + } + + // Helper: Calculate positions for a ring at a given time + function getPositionsAtTime(ringIndex, time) { + return RING_CONFIGS[ringIndex].velocities.map((v, i) => { + const offset = (runnerOffsets[ringIndex] && runnerOffsets[ringIndex][i]) || 0; + return ((time * v * 0.5 * ANIMATION_SPEED) + offset) % TAU; + }); + } + + // Helper: Check if a runner is lonely (min distance to others >= TAU/N) + function isRunnerLonely(ringIndex, runnerIndex, time) { + const positions = getPositionsAtTime(ringIndex, time); + const N = positions.length; + const lonelyThreshold = TAU / N; + const runnerPos = positions[runnerIndex]; + + let minDist = TAU; + for (let i = 0; i < N; i++) { + if (i === runnerIndex) continue; + const d = Math.abs(runnerPos - positions[i]); + const dist = Math.min(d, TAU - d); + if (dist < minDist) minDist = dist; + } + + return minDist >= lonelyThreshold; + } + + // Helper: Calculate mouse angle relative to center + function getMouseAngle(mouseX, mouseY) { + const dx = mouseX - width / 2; + // Flip Y for standard cartesian (Up is +Y), matching shader logic/view + const dy = -(mouseY - height / 2); + let angle = Math.atan2(dy, dx); + if (angle < 0) angle += TAU; + return angle; + } + + // Helper: Detect which runner (if any) was clicked + // Returns {ringIndex, runnerIndex} or null + function getClickedRunner(mouseX, mouseY, time) { + // Convert mouse coordinates to UV space (same as shader) + const minDim = Math.min(width, height); + const uvScale = 2.0 / minDim; + + // p5 WEBGL mode has origin at center, so adjust + const uvX = (mouseX - width / 2) * uvScale; + const uvY = -(mouseY - height / 2) * uvScale; // Flip Y for WebGL + + // Convert to polar + const dist = Math.sqrt(uvX * uvX + uvY * uvY); + let angle = Math.atan2(uvY, uvX); + if (angle < 0) angle += TAU; + + // Calculate geometry parameters + const thicknessNorm = RING_THICKNESS_PX * uvScale; + const spacingNorm = RING_SPACING_PX * uvScale; + const innerRadiusNorm = INNER_RADIUS_PX * uvScale; + + // Find which ring (if any) + for (let ringIndex = 0; ringIndex < RING_CONFIGS.length; ringIndex++) { + const trackRadius = innerRadiusNorm + ringIndex * spacingNorm; + const trackHalfWidth = thicknessNorm * 0.5; + + if (Math.abs(dist - trackRadius) <= trackHalfWidth) { + // On this ring! Now find which runner + const positions = getPositionsAtTime(ringIndex, time); + + for (let runnerIndex = 0; runnerIndex < positions.length; runnerIndex++) { + const runnerAngle = positions[runnerIndex]; + + // Calculate angular distance + const d = Math.abs(angle - runnerAngle); + const angularDist = Math.min(d, TAU - d); + + // Calculate loneliness for this runner + const N = positions.length; + let minDistToOthers = TAU; + for (let i = 0; i < N; i++) { + if (i === runnerIndex) continue; + const d = Math.abs(positions[runnerIndex] - positions[i]); + const dist = Math.min(d, TAU - d); + if (dist < minDistToOthers) minDistToOthers = dist; + } + const halfLoneliness = minDistToOthers * 0.5; + + // Check if click is within this runner's segment + if (angularDist <= halfLoneliness) { + return {ringIndex, runnerIndex}; + } + } + } + } + + return null; + } + + // Audio system - continuous drones per ring + + let audioContext = null; + let masterGain = null; // Master gain for gentle overall fade-in + let reverbNode = null; // Convolver for frozen reverb + let reverbGain = null; // Wet signal gain + let ringOscillators = []; // {osc1, osc2, osc3, gainNode, filterNode} per ring + let ringLonelyCounts = []; // number of lonely runners per ring + let audioJustInitialized = false; // Flag for gentle first startup + + // Generate a long, lush impulse response for frozen reverb + function createReverbImpulse(duration, decay, reverse = false) { + const sampleRate = audioContext.sampleRate; + const length = sampleRate * duration; + const impulse = audioContext.createBuffer(2, length, sampleRate); + const left = impulse.getChannelData(0); + const right = impulse.getChannelData(1); + + for (let i = 0; i < length; i++) { + const n = reverse ? length - i : i; + // Exponential decay with some randomness for natural sound + const t = n / length; + const envelope = Math.pow(1 - t, decay); + + // White noise with exponential decay + left[i] = (Math.random() * 2 - 1) * envelope; + right[i] = (Math.random() * 2 - 1) * envelope; + } + + return impulse; + } + + function initAudio() { + if (audioContext) return; + + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + audioJustInitialized = true; + + // Create master gain node for overall volume control + masterGain = audioContext.createGain(); + masterGain.connect(audioContext.destination); + + // Start master gain at very low level + masterGain.gain.setValueAtTime(0.0001, audioContext.currentTime); + // Gentle fade-in over 10 seconds, but only if not muted + const targetGain = isMuted ? 0.0001 : 1.0; + masterGain.gain.exponentialRampToValueAtTime(targetGain, audioContext.currentTime + 7.0); + + // Create frozen reverb + reverbNode = audioContext.createConvolver(); + // Long reverb tail (8 seconds) with slow decay (1.2 = very slow) + reverbNode.buffer = createReverbImpulse(8.0, 1.2); + + reverbGain = audioContext.createGain(); + reverbGain.gain.value = 0.5; // 50% wet signal + + // Create a feedback delay for the "frozen" effect + const feedbackDelay = audioContext.createDelay(5.0); + feedbackDelay.delayTime.value = 0.3; // 300ms delay + + const feedbackGain = audioContext.createGain(); + feedbackGain.gain.value = 0.7; // High feedback for frozen effect + + const reverbFilter = audioContext.createBiquadFilter(); + reverbFilter.type = 'lowpass'; + reverbFilter.frequency.value = 3000; // Darken the reverb tail + + // Reverb signal path: reverb -> filter -> delay -> feedback -> master + reverbNode.connect(reverbFilter); + reverbFilter.connect(feedbackDelay); + feedbackDelay.connect(feedbackGain); + feedbackGain.connect(feedbackDelay); // Feedback loop + feedbackDelay.connect(reverbGain); + reverbGain.connect(masterGain); + + // Create persistent oscillator + gain for each ring + RING_CONFIGS.forEach((config, ringIndex) => { + // Use configurable scale + const scaleIndex = ringIndex % DRONE_SCALE.length; + const freq = DRONE_BASE_FREQ * DRONE_SCALE[scaleIndex] * Math.pow(2, Math.floor(ringIndex / DRONE_SCALE.length)); + + // Create three detuned oscillators for texture + const osc1 = audioContext.createOscillator(); + const osc2 = audioContext.createOscillator(); + const osc3 = audioContext.createOscillator(); + + // Low-pass filter for warmth + const filterNode = audioContext.createBiquadFilter(); + filterNode.type = 'lowpass'; + filterNode.frequency.value = 800; + filterNode.Q.value = 0.5; + + const gainNode = audioContext.createGain(); + + // Mix of waveforms for richness + osc1.type = 'sawtooth'; + osc2.type = 'triangle'; + osc3.type = 'sine'; + + // Slight detuning for chorus effect + osc1.frequency.value = freq; + osc2.frequency.value = freq * 1.003; // +3 cents + osc3.frequency.value = freq * 0.997; // -3 cents + + // Connect: oscillators -> filter -> gain -> (dry: master, wet: reverb) + osc1.connect(filterNode); + osc2.connect(filterNode); + osc3.connect(filterNode); + filterNode.connect(gainNode); + + // Dry signal to master + gainNode.connect(masterGain); + // Wet signal to reverb + gainNode.connect(reverbNode); + + // Start at minimum audible level (exponentialRamp can't start from 0) + gainNode.gain.setValueAtTime(0.001, audioContext.currentTime); + + osc1.start(); + osc2.start(); + osc3.start(); + + ringOscillators.push({osc1, osc2, osc3, gainNode, filterNode}); + ringLonelyCounts.push(0); + }); + } + + function updateRingDrone(ringIndex, lonelyCount) { + if (!audioContext) return; // Don't update if audio not initialized yet + + const prevCount = ringLonelyCounts[ringIndex]; + if (lonelyCount === prevCount) return; // No change + + const {gainNode} = ringOscillators[ringIndex]; + const now = audioContext.currentTime; + + // Calculate target volume based on number of lonely runners + // Volume scales with count: 0 lonely = silent, max lonely = max volume + const N = RING_CONFIGS[ringIndex].velocities.length; + const volumeScale = lonelyCount / N; // 0 to 1 + const maxVolume = 0.06; // Max volume per ring + const targetVolume = lonelyCount > 0 ? 0.001 + (volumeScale * maxVolume) : 0.001; + + // Determine fade time + const fadeTime = audioJustInitialized ? 8.0 : + (lonelyCount > prevCount ? 2.0 : 3.0); // Faster fade in, slower fade out + + gainNode.gain.cancelScheduledValues(now); + gainNode.gain.setValueAtTime(gainNode.gain.value, now); + gainNode.gain.exponentialRampToValueAtTime(targetVolume, now + fadeTime); + + ringLonelyCounts[ringIndex] = lonelyCount; + } p.setup = async () => { - startStamp = Date.now() / 1000.0; + // Offset so we start with an interesting configuration + startStamp = Date.now() / 1000.0 + 36000; width = p._userNode.offsetWidth; height = p._userNode.offsetHeight; - + // Initialize canvas p.createCanvas(width, height, p.WEBGL); p.imageMode(p.CENTER); - p.rectMode(p.CENTER); // Ensure quads are drawn from center + p.rectMode(p.CENTER); p.noStroke(); // Create Shader const shaderData = createRunnerRingFragmentShader(RING_CONFIGS); runnerShader = p.createShader(DEFAULT_VERTEX_SHADER, shaderData.shader); p.shader(runnerShader); + + // Set constant uniforms once + RING_CONFIGS.forEach((config, i) => { + runnerShader.setUniform(`lonelyThreshold_${i}`, config.lonelyThreshold); + }); + + // Create color texture + const maxRunners = Math.max(...RING_CONFIGS.map(c => c.velocities.length)); + const numRings = RING_CONFIGS.length; - // Set static uniforms for each ring (colors) - RING_CONFIGS.forEach((config, index) => { - // Velocities not needed in shader anymore - runnerShader.setUniform(`colors_${index}`, config.colors.flat()); + // p5 Graphics acts as a texture + colorTexture = p.createGraphics(maxRunners, numRings); + colorTexture.pixelDensity(1); + colorTexture.loadPixels(); + + for (let i = 0; i < numRings; i++) { + const config = RING_CONFIGS[i]; + for (let j = 0; j < config.colors.length; j++) { + const c = config.colors[j]; + const r = (c >> 16) & 0xFF; + const g = (c >> 8) & 0xFF; + const b = c & 0xFF; + + // Index in pixels array: 4 * (y * width + x) + const idx = 4 * (i * maxRunners + j); + + colorTexture.pixels[idx] = r; + colorTexture.pixels[idx + 1] = g; + colorTexture.pixels[idx + 2] = b; + colorTexture.pixels[idx + 3] = 255; + } + } + colorTexture.updatePixels(); + + // Initialize/Reset state tracking + hasBeenLonely = []; + runnerOffsets = []; + let totalRunners = 0; + RING_CONFIGS.forEach((config) => { + hasBeenLonely.push(new Array(config.velocities.length).fill(false)); + runnerOffsets.push(new Array(config.velocities.length).fill(0.0)); + totalRunners += config.velocities.length; }); + + // Setup UI + lonelyCountEl = document.getElementById('lonely-count'); + totalRunnersEl = document.getElementById('total-runners'); + lonelyCounterContainer = document.getElementById('lonely-counter'); + muteButtonEl = document.getElementById('mute-toy'); + + if (muteButtonEl) { + muteButtonEl.addEventListener('click', (e) => { + e.stopPropagation(); + if (!audioContext) { + initAudio(); + } + if (!hasUserInteractedWithCanvas) { + hasUserInteractedWithCanvas = true; + } + toggleMute(); + }); + } + + if (totalRunnersEl) { + totalRunnersEl.textContent = totalRunners; + } + if (lonelyCounterContainer) { + lonelyCounterContainer.style.opacity = 1; + } + } + + + p.mousePressed = function () { + // Initialize audio on first interaction + if (!audioContext) { + initAudio(); + } + + hasUserInteractedWithCanvas = true; + + const time = Date.now() / 1000.0 - startStamp; + const clicked = getClickedRunner(p.mouseX, p.mouseY, time); + + if (clicked) { + const {ringIndex, runnerIndex} = clicked; + // Start dragging + draggingRunner = {ringIndex, runnerIndex}; + lastMouseAngle = getMouseAngle(p.mouseX, p.mouseY); + } + } + + p.mouseDragged = function() { + if (draggingRunner) { + const currentMouseAngle = getMouseAngle(p.mouseX, p.mouseY); + let dAngle = currentMouseAngle - lastMouseAngle; + + // Handle wrapping + if (dAngle > Math.PI) dAngle -= TAU; + if (dAngle < -Math.PI) dAngle += TAU; + + const {ringIndex, runnerIndex} = draggingRunner; + runnerOffsets[ringIndex][runnerIndex] += dAngle; + + lastMouseAngle = currentMouseAngle; + } + return false; // Prevent default + } + + p.mouseReleased = function() { + draggingRunner = null; } + // Support touch events for mobile + p.touchStarted = function() { + // Initialize audio on first interaction + if (!audioContext) { + initAudio(); + } + + hasUserInteractedWithCanvas = true; + + if (p.touches.length === 1) { + // Simulate mouse press with first touch + const touch = p.touches[0]; + p.mouseX = touch.x; + p.mouseY = touch.y; + p.mousePressed(); + return false; // Prevent default + } + } + + p.touchMoved = function() { + if (p.touches.length === 1) { + const touch = p.touches[0]; + p.mouseX = touch.x; + p.mouseY = touch.y; + p.mouseDragged(); + return false; + } + } + + p.touchEnded = function() { + p.mouseReleased(); + return false; + } + + p.draw = function () { p.clear(); - + + // Calculate current time (continuous animation) + const time = Date.now() / 1000.0 - startStamp; + // Calculate geometry parameters in Normalized Device Coordinates (UV space) - // UV space goes from -1 to 1 along the shortest dimension. - // So 1 unit = min(width, height) / 2 pixels. const minDim = Math.min(width, height); - const uvScale = 2.0 / minDim; // 1 pixel = uvScale units - + const uvScale = 2.0 / minDim; + const thicknessNorm = RING_THICKNESS_PX * uvScale; const spacingNorm = RING_SPACING_PX * uvScale; const innerRadiusNorm = INNER_RADIUS_PX * uvScale; - + // Pass dynamic uniforms - const time = (Date.now() / 10000.0 - startStamp); runnerShader.setUniform("time", time); - // Fix for high-DPI displays: pass physical resolution runnerShader.setUniform("resolution", [width * p.pixelDensity(), height * p.pixelDensity()]); - - // Pass geometry params runnerShader.setUniform("u_geom_params", [thicknessNorm, spacingNorm, innerRadiusNorm]); - runnerShader.setUniform("u_use_colors", USE_DISTINCT_COLORS); - runnerShader.setUniform("u_highlight_lonely", HIGHLIGHT_LONELY); + runnerShader.setUniform("u_show_center_dots", SHOW_CENTER_DOTS); - const TAU = Math.PI * 2; + // Bind color texture + runnerShader.setUniform("u_color_texture", colorTexture); + runnerShader.setUniform("u_texture_size", [colorTexture.width, colorTexture.height]); + + let totalLonely = 0; RING_CONFIGS.forEach((config, i) => { // 1. Calculate all positions for this ring - const currentPositions = config.velocities.map(v => (time * v * 0.5) % TAU); - + const currentPositions = config.velocities.map((v, j) => { + const offset = runnerOffsets[i][j]; + return ((time * v * 0.5 * ANIMATION_SPEED) + offset) % TAU; + }); + // 2. Calculate loneliness (buffer size) for each runner const loneliness = currentPositions.map((pos1, j) => { let minDist = TAU; currentPositions.forEach((pos2, k) => { if (j === k) return; let d = Math.abs(pos1 - pos2); - // Handle wrap-around distance let dist = Math.min(d, TAU - d); if (dist < minDist) minDist = dist; }); - // Territory is half the distance to the nearest neighbor - return minDist * 0.5; + return minDist * 0.5; }); - // 3. Update uniforms - // Use Float32Array for safe WebGL uniform passing + // 3. Count lonely runners in this ring and track first-time loneliness + let lonelyCount = 0; + for (let j = 0; j < config.velocities.length; j++) { + const isLonely = isRunnerLonely(i, j, time); + if (isLonely) { + lonelyCount++; + // Mark this runner as having been lonely + if (!hasBeenLonely[i][j]) { + hasBeenLonely[i][j] = true; + } + } + + if (hasBeenLonely[i][j]) { + totalLonely++; + } + } + + // Update drone for this ring based on lonely count + updateRingDrone(i, lonelyCount); + + // 4. Update uniforms + const N = config.velocities.length; + runnerShader.setUniform(`positions_${i}`, new Float32Array(currentPositions)); runnerShader.setUniform(`loneliness_${i}`, new Float32Array(loneliness)); + // Convert boolean array to integer array for WebGL + runnerShader.setUniform(`hasBeenLonely_${i}`, hasBeenLonely[i].map(b => b ? 1 : 0)); }); - // Draw a rect covering the screen to run the shader + // Update UI counter + if (lonelyCountEl) { + lonelyCountEl.textContent = totalLonely; + } + + // After first update cycle, disable the extra-gentle fade-in + if (audioJustInitialized) { + audioJustInitialized = false; + } + + // Draw p.rect(0, 0, width, height); } diff --git a/pages/drumheller-marathon-26.html b/pages/drumheller-marathon-26.html index 380ea28..5c001d1 100644 --- a/pages/drumheller-marathon-26.html +++ b/pages/drumheller-marathon-26.html @@ -24,7 +24,7 @@ - + @@ -195,7 +195,7 @@ transition: text-shadow .3s, color .3s; } a:hover { - text-shadow: 0 0 1rem rgba(225, 131, 0, 1.0) + //text-shadow: 0 0 1rem rgba(225, 131, 0, 1.0) } a.disabled { @@ -263,13 +263,13 @@ #hero-mechanism { width: 200px; opacity: 1.0; + pointer-events: none; } #hero .hero-container { max-width: 80rem; margin: 0 auto auto auto; padding: clamp(.75rem,min(5vh, 10vw),3rem) 1rem; overflow-x: clip; /* Allow underlay gradient to spill out */ - } #hero h1 { @@ -277,11 +277,25 @@ text-transform: uppercase; font-size: clamp(.75rem,min(8vh, 8vw),20rem); line-height: 0.8; + letter-spacing: 0.2rem; } #hero { height: 100%; position: relative; + --shadow: black; + text-shadow: .25px .25px 0 var(--shadow), .5px .5px 0 var(--shadow), .75px .75px 0 var(--shadow), 1px 1px 0 var(--shadow), + 1.25px 1.25px 0 var(--shadow), 1.5px 1.5px 0 var(--shadow), 1.75px 1.75px 0 var(--shadow), 2px 2px 0 var(--shadow), + 2.25px 2.25px 0 var(--shadow), 2.5px 2.5px 0 var(--shadow), 2.75px 2.75px 0 var(--shadow), 3px 3px 0 var(--shadow), + 3.25px 3.25px 0 var(--shadow), 3.5px 3.5px 0 var(--shadow), 3.75px 3.75px 0 var(--shadow), 4px 4px 0 var(--shadow), + 4.25px 4.25px 0 var(--shadow), 4.5px 4.5px 0 var(--shadow), 4.75px 4.75px 0 var(--shadow), 5px 5px 0 var(--shadow), + 5.25px 5.25px 0 var(--shadow), 5.5px 5.5px 0 var(--shadow), 5.75px 5.75px 0 var(--shadow), 6px 6px 0 var(--shadow), + 6.25px 6.25px 0 var(--shadow), 6.5px 6.5px 0 var(--shadow), 6.75px 6.75px 0 var(--shadow), 7px 7px 0 var(--shadow), + 7.25px 7.25px 0 var(--shadow), 7.5px 7.5px 0 var(--shadow), 7.75px 7.75px 0 var(--shadow), 8px 8px 0 var(--shadow), + 8.25px 8.25px 0 var(--shadow), 8.5px 8.5px 0 var(--shadow), 8.75px 8.75px 0 var(--shadow), 9px 9px 0 var(--shadow), + 9.25px 9.25px 0 var(--shadow), 9.5px 9.5px 0 var(--shadow), 9.75px 9.75px 0 var(--shadow), 10px 10px 0 var(--shadow), + 10.25px 10.25px 0 var(--shadow), 10.5px 10.5px 0 var(--shadow), 10.75px 10.75px 0 var(--shadow), 11px 11px 0 var(--shadow), + 11.25px 11.25px 0 var(--shadow), 11.5px 11.5px 0 var(--shadow), 11.75px 11.75px 0 var(--shadow), 12px 12px 0 var(--shadow); } #hero-toy { @@ -325,24 +339,15 @@ line-height: 1.3; font-weight: 300; --shadow: black; - text-shadow: .25px .25px 0 var(--shadow), .5px .5px 0 var(--shadow), .75px .75px 0 var(--shadow), 1px 1px 0 var(--shadow), - 1.25px 1.25px 0 var(--shadow), 1.5px 1.5px 0 var(--shadow), 1.75px 1.75px 0 var(--shadow), 2px 2px 0 var(--shadow), - 2.25px 2.25px 0 var(--shadow), 2.5px 2.5px 0 var(--shadow), 2.75px 2.75px 0 var(--shadow), 3px 3px 0 var(--shadow), - 3.25px 3.25px 0 var(--shadow), 3.5px 3.5px 0 var(--shadow), 3.75px 3.75px 0 var(--shadow), 4px 4px 0 var(--shadow), - 4.25px 4.25px 0 var(--shadow), 4.5px 4.5px 0 var(--shadow), 4.75px 4.75px 0 var(--shadow), 5px 5px 0 var(--shadow), - 5.25px 5.25px 0 var(--shadow), 5.5px 5.5px 0 var(--shadow), 5.75px 5.75px 0 var(--shadow), 6px 6px 0 var(--shadow), - 6.25px 6.25px 0 var(--shadow), 6.5px 6.5px 0 var(--shadow), 6.75px 6.75px 0 var(--shadow), 7px 7px 0 var(--shadow), - 7.25px 3.25px 0 var(--shadow), 7.5px 7.5px 0 var(--shadow), 7.75px 7.75px 0 var(--shadow), 8px 8px 0 var(--shadow); + } .display-text a { color: #ddd; transition: text-shadow .3s; --shadow: black; - text-shadow: .25px .25px 0 var(--shadow), .5px .5px 0 var(--shadow), .75px .75px 0 var(--shadow), 1px 1px 0 var(--shadow); } .display-text a:hover { --shadow: rgba(30, 30, 30, 1.0); - text-shadow: 1px 1px 0 var(--shadow), 2px 2px 0 var(--shadow); } .wavy-button, .wave-button:hover, .wave-button:visited, .wave-button:active { @@ -350,19 +355,17 @@ } .wavy-button { text-decoration-thickness: .5px; - text-decoration-style: wavy; text-decoration-color: rgba(255,255,255,.3); text-underline-offset: 2px; transition: text-shadow .3s, text-decoration .3s, font-weight .3s; - text-shadow: 1px 1px 0px rgba(0, 0, 0, 0); } .wavy-button:hover { - text-shadow: .25px .25px 0px rgb(0, 239, 255, 0.5), .5px .5px 0px rgb(0, 239, 255, 0.5),.75px .75px 0px rgb(0, 239, 255, 0.5), 1px 1px 0px rgb(0, 239, 255, 0.5); + --shadow: rgb(0, 239, 255, 0.5); } .wavy-button.complement-color:hover { - text-shadow: .25px .25px 0px rgb(224, 0, 255), .5px .5px 0px rgb(224, 0, 255), .75px .75px 0px rgb(224, 0, 255), 1px 1px 0px rgb(224, 0, 255); + --shadow: rgb(224, 0, 255); } .wavy-button.disabled { @@ -893,6 +896,8 @@
+ +
@@ -901,8 +906,14 @@
-
The Race Condition Running

Drumheller Marathon

+
The Race Condition Running

Drumheller
Marathon

+
+ +
+ 0 / 0 +
+
@@ -1006,74 +1012,8 @@

Travel -

Race Day

-
    -
  • Bring a water bottle
  • -
  • Be at the start line 10 minutes early for course instructions and a photo
  • -
-

Schedule

- - - -

Marathon Start List

- Updated 5/30/25 - - - - - - - - - - - - -
NameGoal
Emma Favier 4:00
Benjamin Han 3:30
Ethan Ancell 4:00
Margaret Li 3:45
Zachary Tatlock 4:00
Julien Luebbers 3:00
Dylan Hull 3:00
- -

Emails

- -

Everything important is above, but participants should have also received the following by email:

- - - Live tracking and safety - 6/02/25 - -

Hello Drumheller runners,

- -

The schedule is posted and our FAQ has never been more comprehensive. Please take a read. Two safety related items:

- -

1. If you are running faster than 7:40/mi (4:45/km) pace over your chosen distance, let me know and plan to start in the early half marathon section. You'll have an easier time and we'll avoid collisions.

- -

2. Bring a cup/bottle. We'll have water tanks out so you can refill, but no vessels.

- -

We are again providing a phone-based lap counting service. The attached QR code is your unique live tracking credential, which is also available with instructions at your private link. There is some setup required, so take a look at your page and the overview on the site. Use it as a backup, and test before the race.

- -

[unique participant QR code]

- -

Lastly, if you won't be running, please fill out the registration with the same account and mark withdraw. Otherwise, you'll keep getting emails.

- -

Forecast is looking good for mountain-gazing, so keep your fingers crossed.

- -

--Nick Walker

- -
-
- -
- - - - -
+
From c99e9d4691534189d50c4ca9147e60666f975ee2 Mon Sep 17 00:00:00 2001 From: Nick Walker Date: Sat, 3 Jan 2026 21:05:14 -0500 Subject: [PATCH 7/7] Scale number of rings to available screen space Don't render if not visible --- js/LonelyRunnerToy.js | 122 ++++++++++++++++++++++-------- pages/drumheller-marathon-26.html | 8 +- 2 files changed, 97 insertions(+), 33 deletions(-) diff --git a/js/LonelyRunnerToy.js b/js/LonelyRunnerToy.js index f3f6f2c..b677801 100644 --- a/js/LonelyRunnerToy.js +++ b/js/LonelyRunnerToy.js @@ -112,13 +112,6 @@ function generateEquidistantRingConfigs(numRings) { return configs; } -const RING_CONFIGS = generateRingConfigs(NUM_RINGS, Math.floor(Date.now() / 1000)); - -// For debugging: -//const RING_CONFIGS = generateEquidistantRingConfigs(NUM_RINGS); - -// For debugging: -//const RING_CONFIGS = generateEquidistantRingConfigs(NUM_RINGS); function createRunnerRingFragmentShader(ringConfigs) { @@ -291,7 +284,10 @@ function createRunnerRingFragmentShader(ringConfigs) { return { shader: frag }; } -export let LonelyRunnerToy = () => p => { +export let LonelyRunnerToy = (numRings = NUM_RINGS) => p => { + const ringConfigs = generateRingConfigs(numRings, Math.floor(Date.now() / 1000)); + // For debugging: + //const ringConfigs = generateEquidistantRingConfigs(NUM_RINGS); let width; let height; let runnerShader; @@ -301,6 +297,10 @@ export let LonelyRunnerToy = () => p => { const TAU = Math.PI * 2; + // Visibility tracking + let isVisible = true; + let observer = null; + // Interaction state let draggingRunner = null; let lastMouseAngle = 0; @@ -344,11 +344,20 @@ export let LonelyRunnerToy = () => p => { if (muteButtonEl) { muteButtonEl.classList.toggle('muted', isMuted); } + + // Suspend audio context when muted to save battery + if (audioContext) { + if (isMuted && audioContext.state === 'running') { + audioContext.suspend(); + } else if (!isMuted && audioContext.state === 'suspended') { + audioContext.resume(); + } + } } // Helper: Calculate positions for a ring at a given time function getPositionsAtTime(ringIndex, time) { - return RING_CONFIGS[ringIndex].velocities.map((v, i) => { + return ringConfigs[ringIndex].velocities.map((v, i) => { const offset = (runnerOffsets[ringIndex] && runnerOffsets[ringIndex][i]) || 0; return ((time * v * 0.5 * ANIMATION_SPEED) + offset) % TAU; }); @@ -412,7 +421,7 @@ export let LonelyRunnerToy = () => p => { } // Find which ring (if any) - for (let ringIndex = 0; ringIndex < RING_CONFIGS.length; ringIndex++) { + for (let ringIndex = 0; ringIndex < ringConfigs.length; ringIndex++) { const trackRadius = innerRadiusNorm + ringIndex * spacingNorm; if (Math.abs(dist - trackRadius) <= thicknessNorm) { @@ -435,7 +444,7 @@ export let LonelyRunnerToy = () => p => { const { angle, dist, uvScale } = getMousePolar(mouseX, mouseY); const ringIndex = getRingIndexFromDist(dist, uvScale); - if (ringIndex === null) return null; + if (ringIndex === null || ringIndex === -1) return null; // On this ring! Now find which runner const positions = getPositionsAtTime(ringIndex, time); @@ -630,7 +639,7 @@ export let LonelyRunnerToy = () => p => { reverbGain.connect(muteGain); // Create persistent oscillator + gain for each ring - RING_CONFIGS.forEach((config, ringIndex) => { + ringConfigs.forEach((config, ringIndex) => { // Use configurable scale const scaleIndex = ringIndex % DRONE_SCALE.length; const freq = DRONE_BASE_FREQ * DRONE_SCALE[scaleIndex] * Math.pow(2, Math.floor(ringIndex / DRONE_SCALE.length)); @@ -692,7 +701,7 @@ export let LonelyRunnerToy = () => p => { // Calculate target volume based on number of lonely runners // Volume scales with count: 0 lonely = silent, max lonely = max volume - const N = RING_CONFIGS[ringIndex].velocities.length; + const N = ringConfigs[ringIndex].velocities.length; const volumeScale = lonelyCount / N; // 0 to 1 const maxVolume = 0.06; // Max volume per ring const targetVolume = lonelyCount > 0 ? 0.001 + (volumeScale * maxVolume) : 0.001; @@ -722,18 +731,18 @@ export let LonelyRunnerToy = () => p => { p.noStroke(); // Create Shader - const shaderData = createRunnerRingFragmentShader(RING_CONFIGS); + const shaderData = createRunnerRingFragmentShader(ringConfigs); runnerShader = p.createShader(DEFAULT_VERTEX_SHADER, shaderData.shader); p.shader(runnerShader); // Set constant uniforms once - RING_CONFIGS.forEach((config, i) => { + ringConfigs.forEach((config, i) => { runnerShader.setUniform(`lonelyThreshold_${i}`, config.lonelyThreshold); }); // Create color texture - const maxRunners = Math.max(...RING_CONFIGS.map(c => c.velocities.length)); - const numRings = RING_CONFIGS.length; + const maxRunners = Math.max(...ringConfigs.map(c => c.velocities.length)); + const numRings = ringConfigs.length; // p5 Graphics acts as a texture colorTexture = p.createGraphics(maxRunners, numRings); @@ -741,7 +750,7 @@ export let LonelyRunnerToy = () => p => { colorTexture.loadPixels(); for (let i = 0; i < numRings; i++) { - const config = RING_CONFIGS[i]; + const config = ringConfigs[i]; for (let j = 0; j < config.colors.length; j++) { const c = config.colors[j]; const r = (c >> 16) & 0xFF; @@ -786,7 +795,7 @@ export let LonelyRunnerToy = () => p => { runnerOffsets = []; ringFlashStates = []; totalRunners = 0; - RING_CONFIGS.forEach((config, ringIndex) => { + ringConfigs.forEach((config, ringIndex) => { hasBeenLonely.push(new Array(config.velocities.length).fill(false)); runnerOffsets.push(new Array(config.velocities.length).fill(0.0)); // DEBUG: set equally around ring @@ -821,8 +830,29 @@ export let LonelyRunnerToy = () => p => { if (lonelyCounterContainer) { lonelyCounterContainer.style.opacity = 1; } + + // Set up intersection observer to pause when not visible + setupVisibilityObserver(); } + function setupVisibilityObserver() { + if ('IntersectionObserver' in window) { + observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + isVisible = entry.isIntersecting; + // Resume audio context if scrolling back and user has unmuted + if (isVisible && audioContext && audioContext.state === 'suspended' && !isMuted) { + audioContext.resume(); + } + // Note: We don't call p.noLoop() - instead we skip rendering in draw() when !isVisible + }); + }, { + threshold: 0.01 // Trigger when 1% of element is visible + }); + + observer.observe(p._userNode); + } + } p.mousePressed = function () { // Initialize audio on first interaction @@ -953,7 +983,7 @@ export let LonelyRunnerToy = () => p => { let totalLonely = 0; - RING_CONFIGS.forEach((config, i) => { + ringConfigs.forEach((config, i) => { // 1. Calculate all positions for this ring const currentPositions = config.velocities.map((v, j) => { const offset = runnerOffsets[i][j]; @@ -1008,6 +1038,28 @@ export let LonelyRunnerToy = () => p => { runnerDataTexture.updatePixels(); + + // Update UI counter with fraction + if (lonelyCountEl) { + lonelyCountEl.textContent = totalLonely; + } + + if (infinitePulseHandle === null && totalLonely === totalRunners && totalRunners > 0) { + infinitePulseHandle = Symbol('infinite-pulse'); + pulseRingsWithLonely(infinitePulseHandle); + } + + // After first update cycle, disable the extra-gentle fade-in + if (audioJustInitialized) { + audioJustInitialized = false; + } + + // Skip rendering if not visible (but keep simulation + audio running) + if (!isVisible) { + return; + } + + // Bind runner data texture runnerShader.setUniform("u_runner_data_texture", runnerDataTexture); runnerShader.setUniform("u_runner_texture_size", [runnerDataTexture.width, runnerDataTexture.height]); @@ -1038,20 +1090,18 @@ export let LonelyRunnerToy = () => p => { // Bind flash texture runnerShader.setUniform("u_flash_texture", flashTexture); - // Update UI counter with fraction - if (lonelyCountEl) { - lonelyCountEl.textContent = totalLonely; - } - - if (infinitePulseHandle === null && totalLonely === totalRunners && totalRunners > 0) { - infinitePulseHandle = Symbol('infinite-pulse'); - pulseRingsWithLonely(infinitePulseHandle); + // Cursor logic + if (draggingRunner) { + p.cursor('grabbing'); + } else { + const hoveredRunner = getClickedRunner(p.mouseX, p.mouseY, time); + if (hoveredRunner) { + p.cursor('grab'); + } else { + p.cursor('pointer'); + } } - // After first update cycle, disable the extra-gentle fade-in - if (audioJustInitialized) { - audioJustInitialized = false; - } //console.log(p.frameRate()) // Draw p.rect(0, 0, width, height); @@ -1062,4 +1112,12 @@ export let LonelyRunnerToy = () => p => { height = p._userNode.offsetHeight; p.resizeCanvas(width, height, true); } + + // Cleanup when p5 instance is removed + p.remove = function() { + if (observer) { + observer.disconnect(); + observer = null; + } + } } diff --git a/pages/drumheller-marathon-26.html b/pages/drumheller-marathon-26.html index 8814189..8715a62 100644 --- a/pages/drumheller-marathon-26.html +++ b/pages/drumheller-marathon-26.html @@ -1315,7 +1315,13 @@

FAQ { + const width = window.screen.width; + if (width < 500) return 7; + if (width < 700) return 14; + return 21; + }; + const toy = new p5(LonelyRunnerToy(getNumRings()), document.querySelector("#hero-toy")); // Friendly errors have a performance penalty. toy.disableFriendlyErrors = true; // Tooltips for sign-up deadlines