This plan covers rendering and interaction optimizations for color e-ink displays (e.g., Boox Tab Ultra C, Kobo Libra Colour). The goal is a configurable system that can adapt to varying e-ink panel capabilities — from slow grayscale panels (~300ms refresh) to newer color panels with faster partial refresh (~150ms).
Target devices run Android or have a Chromium-based browser with WebGL support (ANGLE/SwiftShader).
What's already done:
- E-ink CSS theme:
[data-theme="eink"]— no shadows, no rounded corners, 2px solid borders, pure B/W UI (style.css) - E-ink colour palette: greyscale S-52 palette in
s52-colours.ts(72 tokens, all monochromatic) - Jump-cut positioning:
ChartModealways usesjumpTo(), neverflyTo()for vessel following updateRateHzsetting: defined insettings.ts(default 1 Hz) but not yet wired to control anything- Course smoothing: 60fps exponential smoothing in
CourseSmoothing.ts— useful on LCD, wasteful on e-ink - No CSS animations: only one
transition: width 0.2son a progress bar; no@keyframesanywhere
What's missing:
- Render loop throttling (currently runs at 60fps via MapLibre
renderevent) - GPS update throttling (simulator is 1Hz, but browser geolocation can be faster)
- MapLibre animation control (pinch-zoom, double-tap zoom,
flyTofor region switching) - Touch debouncing for e-ink input lag
- Full-screen refresh trigger (to clear ghosting)
prefers-reduced-motionmedia query support
- Configurable, not hardcoded — e-ink panels vary widely; expose knobs rather than assuming worst-case
- Single boolean gate — one
einkMode: booleansetting controls the overall behaviour; individual sub-settings allow fine-tuning - No separate code paths where possible — prefer parameterizing existing code (throttle intervals, animation durations) over if/else branches
- Degrade gracefully — if a setting isn't available, fall back to sensible defaults
- Test without hardware — all e-ink behaviour should be activatable in desktop Chrome for development
File: src/settings.ts
Add a top-level einkMode: boolean (default false). When enabled, it:
- Forces
displayThemeto"eink"(or allows user override to day/dusk for color e-ink) - Sets default
updateRateHzto 1 - Disables MapLibre animations (see §3)
- Enables touch debouncing (see §5)
Auto-detection: no reliable API exists. Offer a toggle in Settings. Optionally detect Boox user-agent strings as a hint to prompt the user.
// settings.ts additions
einkMode: boolean; // default false
einkRefreshMs: number; // default 200 — minimum interval between render frames
einkDisableAnimations: boolean; // default true when einkMode onWhen einkMode flips on, apply all sub-settings as defaults (user can still override individually).
Files: src/main.ts (render event handler), src/navigation/CourseSmoothing.ts
Currently, map.on("render", ...) fires at 60fps and calls courseSmoother.smooth() + triggerRepaint() on every frame.
Change: Gate the render callback with a timestamp check:
let lastRenderTime = 0;
const minRenderInterval = settings.einkMode ? settings.einkRefreshMs : 0;
map.on("render", () => {
const now = performance.now();
if (now - lastRenderTime < minRenderInterval) return;
lastRenderTime = now;
// existing smoothing + repaint logic
});When einkRefreshMs is 200ms, this limits to ~5fps. At 500ms, ~2fps. At 1000ms, 1fps.
Course smoothing: when einkMode is on, skip exponential smoothing entirely — use raw (or lightly averaged) GPS values. Smooth animation between GPS fixes is counterproductive on e-ink since it causes ghosting. Set smoothing tau values to 0 (or bypass the smoother).
File: src/main.ts, src/vessel/ChartMode.ts
MapLibre has built-in animations for:
- Double-tap zoom: animated zoom-in
- Pinch-zoom: animated inertia after release
flyTo(): smooth camera transition (used for region switching)- Scroll-wheel zoom: animated zoom steps
In e-ink mode, disable or shorten these:
if (settings.einkMode && settings.einkDisableAnimations) {
// On map creation or settings change:
map.dragRotate.disable(); // optional: rotation causes heavy ghosting
// Override flyTo to use jumpTo:
const origFlyTo = map.flyTo.bind(map);
map.flyTo = (opts) => { map.jumpTo(opts); return map; };
// Or set animation duration to 0 for all easeTo/flyTo calls
}MapLibre Map constructor options to set:
fadeDuration: 0— disables tile fade-in (important! default 300ms causes ghosting)bearingSnap: 0— disable bearing snap animation- Consider
renderWorldCopies: falseto reduce rendering work
For zoom animations, MapLibre doesn't have a single "disable all animations" flag. Options:
- Set
map.scrollZoom.setZoomRate()andmap.scrollZoom.setWheelZoomRate()to high values (instant zoom) - Patch
easeToto use duration 0 when einkMode is on - Use
map.on("movestart", ...)to callmap.stop()to cancel in-progress animations
Recommended approach: Monkey-patch map.easeTo and map.flyTo to force duration: 0 when einkMode is on. This catches all animation sources without needing to find each call site.
Files: src/navigation/NavigationDataManager.ts, src/main.ts
Wire the existing updateRateHz setting to throttle navigation data broadcasts:
// In NavigationDataManager, throttle subscriber notifications
private lastBroadcast = 0;
private broadcast(data: NavigationData) {
const now = performance.now();
const interval = 1000 / settings.updateRateHz;
if (now - lastBroadcast < interval) return;
lastBroadcast = now;
// notify subscribers...
}For e-ink, default updateRateHz to 1 (one position update per second). User can lower to 0.5 or raise to 2 depending on panel speed.
Vessel layer: Currently updates on every navigation broadcast. With throttling in the manager, vessel position updates naturally slow down. No changes needed in VesselLayer.ts.
File: new src/utils/eink-touch.ts or integrated into existing event handlers
E-ink touchscreens have higher latency (~50-100ms) and less precise digitizers. Optimizations:
- Debounce tap events (50ms) to avoid double-fires from slow panel refresh
- Increase touch target sizes via CSS: min 48px for all interactive elements (buttons, selects, sliders)
- Disable drag-rotate (two-finger rotate) — it causes heavy ghosting and is rarely needed on a boat
- Larger hit areas for map features: increase
queryRenderedFeaturesbbox padding from 20px to 40px
CSS additions for [data-theme="eink"]:
[data-theme="eink"] button,
[data-theme="eink"] select,
[data-theme="eink"] input {
min-height: 48px;
min-width: 48px;
}File: new src/ui/EinkRefreshButton.ts or addition to existing controls
E-ink panels accumulate ghosting artifacts. Provide:
- Manual refresh button: forces a full-screen repaint by briefly toggling visibility or using a CSS hack (flash white → redraw)
- Auto-refresh timer: optional, every N minutes (configurable, default off)
Implementation approach:
function forceFullRefresh() {
// Force MapLibre to do a clean redraw
const container = document.getElementById("map")!;
container.style.display = "none";
requestAnimationFrame(() => {
container.style.display = "";
map.resize();
map.triggerRepaint();
});
}Some Boox devices expose a system API for triggering e-ink refresh modes. If accessible from the browser (unlikely), use it. Otherwise the CSS toggle approach works.
Files: src/ui/NavigationHUD.ts, src/ui/InstrumentHUD.ts
HUD overlays (position, speed, course) update independently of the map. Currently they're DOM elements overlaid on the map canvas — this is already good for e-ink since DOM updates don't trigger a full WebGL redraw.
Ensure:
- HUD text changes use
textContentassignment (notinnerHTMLwhich causes layout reflow) - HUD containers have
will-change: contentsor are positioned withtransformto get their own compositor layer — already the case since they useposition: fixed/absolute - In einkMode, only update HUD text when values actually change (avoid re-rendering identical text)
File: src/chart/s52-colours.ts
The current e-ink palette is pure greyscale. For color e-ink (Kaleido, Gallery 3):
- Keep the existing greyscale palette as
"eink-mono" - Add a
"eink-color"palette: muted, high-saturation versions of the day palette- Color e-ink panels have limited gamut (~4096 colors) and low saturation
- Boost saturation and contrast vs. day palette
- Reduce gradients (e-ink dithers badly on smooth gradients)
- Keep buoy/light colours distinct (critical for navigation safety)
For now, the existing day palette works acceptably on color e-ink. The greyscale palette is there for monochrome panels. This can be refined once testing on real hardware.
File: src/chart/ChartManager.ts
When creating the MapLibre map in e-ink mode, set:
const map = new maplibregl.Map({
// ...existing options
fadeDuration: settings.einkMode ? 0 : 300,
});fadeDuration: 0 eliminates the tile fade-in animation, which is one of the most visible sources of ghosting on e-ink. Tiles snap in instantly.
Also consider map.setMaxTileCacheSize() — larger cache reduces tile reloading and thus repaints.
| Setting | Type | Default (LCD) | Default (E-Ink) | Description |
|---|---|---|---|---|
einkMode |
boolean | false | true | Master e-ink toggle |
einkRefreshMs |
number | 0 | 200 | Min ms between render frames |
einkDisableAnimations |
boolean | false | true | Force jumpTo, no fade, no inertia |
updateRateHz |
number | 10 | 1 | GPS/nav data broadcast rate |
displayTheme |
string | "day" | "eink" | Colour scheme (greyscale or muted color) |
Advanced (future):
| Setting | Type | Default | Description |
|---|---|---|---|
einkAutoRefreshMin |
number | 0 | Auto full-refresh interval (0=off) |
einkColorMode |
"mono"|"color" | "color" | Greyscale vs color e-ink palette |
einkDragRotate |
boolean | false | Allow two-finger rotation |
- Wire
updateRateHz— throttle nav data in NavigationDataManager (small, testable) - Render loop throttle — gate the
renderevent callback witheinkRefreshMs fadeDuration: 0— set on map creation when einkMode is on- Animation patching — monkey-patch
easeTo/flyToto forceduration: 0 - Course smoothing bypass — skip exponential smoothing in einkMode
- Touch target CSS — increase sizes for
[data-theme="eink"] - Full-screen refresh button — add to topbar when einkMode is on
- Test on real hardware — validate all of the above, tune
einkRefreshMsdefault
Steps 1-6 are all testable on desktop Chrome with the e-ink mode toggle. Step 7 is a small UI addition. Step 8 requires a device.
All e-ink optimizations can be verified on desktop:
- Toggle
einkModein settings - Verify render loop fires at throttled rate (add
console.countor FPS counter) - Verify no animations play (zoom, pan, tile fade)
- Verify touch targets meet 48px minimum (Playwright viewport test)
- Verify HUD updates at
updateRateHzrate - Chrome DevTools "Rendering" → "Frame Rendering Stats" to confirm reduced frame rate
- Playwright E2E: enable einkMode, verify no
transition/animationCSS properties active
Real device testing is needed for:
- Ghosting evaluation (tuning
einkRefreshMs) - Touch latency / debounce tuning
- Full-screen refresh effectiveness
- Battery life impact
- WebGL performance (SwiftShader fallback if no GPU)