> building-heu-keep-a-keep-style-workout-card-generator

Building HEU-keep: A Keep-Style Workout Card Generator
// ─────────────────────────────────────────────────────────────
OUTPUT 005 SEED 1133785527 DATE 2026-02-20 CHARS 12,966 TAGS javascript canvas html2canvas indexeddb keep heu algorithms

What is HEU-keep

HEU-keep is a browser-based generator that produces workout summary cards visually identical to the ones from the Keep fitness app. The difference: the track overlays are drawn on actual Harbin Engineering University campus maps — South Field, Jungong Field, and North Field — not a generic template. Fill in distance, pace, and environmental data on one side; watch the preview update live on the other; download as a high-resolution PNG.

Why I built it

I was running regularly on campus through winter 2025. The Keep app generates a nice summary card after each outdoor run: a map with your GPS track overlaid, plus pace, duration, calories, and weather. I wanted to produce these cards without actually needing the app — for sharing on social media, or for runs I tracked manually. All three HEU sports fields have distinct geometries, so generic templates looked wrong. I wanted the track to look like it belonged on that specific field.

The project started as a single HTML file with inline CSS and a Canvas element. Over two months of sporadic evening work it grew to 11 JS modules, two visual themes, and a Python Flask backend for server-side track generation.

The Stack

  • Frontend: Vanilla HTML + CSS + JavaScript. No framework, no npm, no webpack. Eleven JS files loaded via script tags in dependency order.
  • Canvas API: All track drawing — both manual (pointer events) and auto-generated — goes through HTML5 Canvas 2D context.
  • Export: html2canvas 1.4.1 renders the preview DOM to a downloadable PNG at user-configurable resolution.
  • Persistence: IndexedDB holds three object stores: user config, portrait image, and custom background map.
  • Backend (optional): Flask + NumPy endpoint at /generate-track for server-side coordinate generation.
  • Fonts: DINCond-Bold.otf (the Keep brand typeface) and STKAITI.TTF for Chinese text rendering.

1. Track Generation: Elliptical Decomposition

The core challenge: generate a running path that looks like a real GPS track — not a perfect geometric oval, but something a human runner would produce. The algorithm in drawMine.js generates a stadium-shaped track (rectangle with semicircular ends) and then corrupts it with layered noise.

1.1 Four-segment lap construction

Each lap is built from four segments, drawn in order to simulate a runner going clockwise around a track:

  1. Right semicircle — from angle -PI/2 (top) to PI/2 (bottom), stepping by STEP / radius radians
  2. Bottom straight — from right to left along the bottom edge, stepping by a fixed pixel distance
  3. Left semicircle — from PI/2 to 1.5*PI, completing the curve
  4. Top straight — from left to right, closing the loop

The stadium track is defined by five parameters: center (cx, cy), straight length, radius, and angular step. Each lap varies the radius by a random offset (±3px) and the center point by a small drift (±1-2px), so consecutive laps do not perfectly overlap.

1.2 Random walk drift

After constructing the geometric lap points, a random walk algorithm is applied to simulate a real runner gradually veering off the ideal line:

wanderX += (Math.random() - 0.5) * 1.5;
wanderY += (Math.random() - 0.5) * 1.5;
wanderX *= 0.95;  // decay — pulls the path back toward center
wanderY *= 0.95;

The 0.95 decay factor is critical. Without it, the wander accumulates unbounded and the path drifts off the map within a few laps. With it, the path meanders locally but stays globally centered on the field. This is essentially a mean-reverting stochastic process — a discrete Ornstein-Uhlenbeck analog.

1.3 GPS jitter

Each point gets an additional ±1px random offset in both X and Y, simulating the quantization noise you see in consumer GPS traces. The combined result — multi-lap structure + wander + jitter — looks convincingly like a real running track when rendered.

1.4 Entry stub and rotation

A 7-point Bezier curve stub is generated from a random off-field origin point (20-50px right, 20-60px above the lap start) and interpolated into the first lap point. A sinusoidal arc curve (sin(t * PI/2) * 10) adds curvature to simulate a runner approaching the track from outside.

Finally, the entire coordinate set is rotated -4 degrees via a 2D rotation matrix, matching the actual orientation of the campus fields as they appear in the satellite basemaps.

1.5 Lap count and cutoff

5 to 8 full laps are generated per track. After the last full lap, an additional partial lap is appended — randomly 10% to 40% of a full lap — so the track does not end exactly at the starting point. This asymmetry makes each generated card look unique.

2. Color Gradient System Along the Path

The track is not a solid green line. When the "Gradient Color" option is enabled, the line color shifts along the path using a per-segment probability-based transition system.

2.1 Color state machine

At each point along the path, a random roll against the configured probability (default 0.5) determines whether to start a color transition. Once a transition begins, it spans a random number of points (the "range", configurable via min/max step size). During the transition, the RGB values interpolate between the current color and a target using a quadratic ease-in/ease-out curve:

var t = bs_now / bs_range;
var ease = 4 * t * (1 - t);
var r = base_r + ease * target_r;
var g = base_g + ease * target_g;
var b = base_b + ease * target_b;

The target color is chosen from one of two palettes depending on a random direction sign. The positive direction shifts toward warm yellow-green; the negative direction shifts toward cool blue-green. The quadratic ease ensures the color changes smoothly — fast in the middle, slow at the edges.

2.2 Canvas linear gradients

Each drawn segment uses ctx.createLinearGradient() between the previous and current point, with the interpolated color at both stops. This means even within a single "step" of the transition, the color is not flat — it gradients across the segment, producing a smooth, continuous color flow along the entire path.

3. Off-Screen Export Pipeline

Getting html2canvas to render the card correctly required solving several problems unique to the CSS-heavy glassmorphism design.

3.1 Ghost DOM cloning

When the user clicks "Save HD Image", the preview card is deep-cloned into an off-screen container positioned at (-9999px, -9999px). The clone width is locked to the original's offsetWidth to prevent reflow.

3.2 Manual canvas state copy

cloneNode(true) does not copy Canvas pixel data — the cloned canvas elements are blank. Each original canvas is manually read and redrawn onto its clone using destCtx.drawImage(orig, 0, 0). This includes both the track overlay canvas and any dynamically generated canvases.

3.3 CSS effect removal

html2canvas cannot render CSS transforms, box-shadows, or transitions correctly. These are stripped from the cloned node before rendering:

clonedNode.style.transform = 'none';
clonedNode.style.boxShadow = 'none';
clonedNode.style.transition = 'none';

The result: a clean, static DOM tree that html2canvas can rasterize without artifacts.

3.4 Three download methods

The exported PNG is delivered via an anchor element with a download attribute (primary method). Two fallback methods exist: opening the data URL in a new tab (for browsers that block direct downloads), and Object URL creation via Blob (for Safari). The filename is auto-generated from the workout date.

4. Live Preview and Derived Metrics

The left panel updates instantly on every keystroke via onchange handlers on all input fields. The setData() function reads all form values, then calls render() which updates the preview DOM.

Two derived metrics are computed client-side:

  • Duration: speed_decimal * distance — converts pace (mm'ss") to decimal minutes, multiplies by distance in km, splits into hours:minutes:seconds
  • Calories: 69 * distance * 1.036 — a simplified running calorie formula (MET-based for 8 km/h pace)

5. IndexedDB Persistence

Three object stores under database MyDatabase:

  • user_info — username, title, range defaults, background selection, color settings, export width, auto-draw toggle
  • user_portrait — the uploaded portrait photo as a base64 data URL
  • user_bgimg — the uploaded custom background map image

On page load, document.dispatchEvent(new Event('dbReady')) signals to onload.js that the database is open. Saved data is read back and written into the form fields via initInputData(). The page requests navigator.storage.persist() to reduce the chance of automatic browser cleanup.

6. Mobile CSS Adaptations

The control panel layout uses a dense flex-based form with minimal whitespace, optimized for phone screens. Several iOS-specific fixes required unusual CSS:

  • Auto-width inputs: A Canvas 2D measureText() singleton calculates the exact pixel width of each input's content, then sets the width via requestAnimationFrame-batched DOM writes. This avoids the default fixed-width inputs that waste horizontal space on short values like "18" or "65".
  • Native value setter interception: For temperature and humidity inputs that receive values programmatically (from a weather API), Object.defineProperty hooks into the input's value setter to trigger width recalculation, since input events do not fire on programmatic changes.
  • Select element sizing: width: fit-content with min-width: 60px and max-width: 65% prevents dropdowns from overflowing on narrow screens.

7. Manual Track Drawing

In addition to random generation, users can draw their own track by hand. The draw_personalization.js module overlays a full-size Canvas on the background map and captures pointerdown/pointermove/pointerup events. Each stroke is recorded as an array of {action, x, y} objects and can be replayed from drawingActions.json.

The color gradient system works identically in manual mode — the same probability-based color shift logic runs on pointer move events, interpolating RGB values along each line segment.

8. Flask Backend (Optional)

Json2Png.py exposes a /generate-track endpoint that produces coordinate arrays using NumPy. The track construction mirrors the frontend logic in Python: ellipse parameterization with segment-based construction, random perturbation, and rotation matrix transformation. The output is a JSON array of {action, x, y} objects that the frontend's Json2Draw() function replays onto the Canvas.

9. Two Visual Themes

The project ships with two entry pages that share all JavaScript logic but use different CSS:

  • Classic (index.html): Glassmorphism card with a frosted-glass effect, ambient background with noise overlay, and rounded form controls. The card uses backdrop-filter: blur() for the glass look.
  • Liquid (liquid.html): Alternative visual treatment with a different card layout and color scheme. Shares all JS modules via the same script dependency chain.

Deployment

Static site hosted on Cloudflare Pages. No build step — push to master and it is live. Demo at master.heu-keep-demo.pages.dev. The Flask backend runs separately on a VPS for the optional server-side track generation feature.

Star on GitHub

See also: HEU-keep Operation Docs — step-by-step user guide covering the workflow, track generation, export, and troubleshooting.

← cd .. /