Caustica is written in vanilla JavaScript with no framework. The source lives in /assets/caustica/.
lens.jsPure physics; no DOM access or rendering.
comovingDist, angDiamDist, angDiamDistBetween: flat ΛCDM distance integrals via midpoint Riemann sum.deflectPointMass, deflectSIE, deflectNIE, deflectEPL: take a ray–lens separation in arcsec and return a deflection angle in arcsec.precomputeDistances(planes): builds the $D_\text{obs}$ and $D_\text{btwn}$ arrays once per plane configuration.traceRay(θ, planes, dist, targetIdx): evaluates the multiplane recursion in JavaScript; used for critical curve sampling.traceSourceGrid(planes, dist, sourceIdx, fov, gridN): traces an $N \times N$ image-plane grid to the source plane and returns $\boldsymbol{\beta}$ at each node. Shared by the critical-curve and disc-outline extractors, both of which are marching squares over the same grid, so one trace can feed both.computeCriticalCurves(planes, dist, sourceIdx, fov, gridN, grid?): computes the Jacobian determinant and trace on the grid by finite differences, then runs marching squares (_marchCell()) to extract critical-curve and caustic segments with per-segment critRadial / causticRadial flags (§9), dropping small radial loops. Accepts a precomputed grid from traceSourceGrid.computeDiscImageOutlines(planes, dist, sourceIdx, source, fov, gridN, grid?): extracts the uniform-disc lensed-image boundary (the zero level-set of $r_\text{ell}(\boldsymbol{\beta}(\boldsymbol{\theta})) - r$) by marching squares over the same $\boldsymbol{\beta}$ grid, with per-vertex secant refinement so high-magnification arcs stay smooth (§9, Line art).chainSegments(segments, tol) and smoothPolylines(polys, opts): link loose marching-squares segments into ordered polylines and apply curvature-aware (cusp-preserving) Chaikin smoothing; used for the line-art curves and disc outlines.chainCurves(critSegs, causSegs, tol): chains the critical curves and gives the caustics the same order, returning { crit, caus, closed }. Chaining caustics by their own points goes wrong near cusps, where the points crowd together. Used by the image-number map.renderer.jsWebGL2 GPU renderer.
setBackdrop(canvas | null) uploads or clears it and the bgOpts property carries the View-tab framing, so it is picked up by every existing setScene() call site without changing that signature. _bgUniforms(fov) turns the fit / zoom / pan / sky-lock controls into the shader’s sample window, keeping that arithmetic in one place next to the texture whose aspect ratio it needs. A fieldColor property carries the flat field colour, set whether or not a picture is loaded since it is the field behind everything. samplePasted() returns the pasted image’s colour and its footprint together as a vec4, sampling from a clamped UV so the mipmapped fetch stays in uniform control flow (§6), and applies the per-image u_pastedTone contrast/saturation pair before the amplitude; the u_pastedStretch flag routes each pasted image into either the accumulator that goes through the brightness stretch or the one added after it (§6), which is what keeps an unstretched photograph’s tones intact; sampleBackdrop() had already been written that way and its comment is what identified the same defect here. Fermat mode additionally receives up to 8 saddle-image $\varphi$ values as a float array uniform so the shader can highlight those contour levels.lensPotential() for analytic per-model potentials and fermatPotential(), which traces the ray and evaluates the comoving arrival-time surface (§4) for the Fermat map. atanh_approx() and atan_stable() switch to their Taylor series for small arguments, which the isothermal deflections need as $q \to 1$ (§4, numerical accuracy)._drawDelensed() in three passes. The first goes through the main program itself (u_vizMode == 9, with u_traceSpan and u_res set to the grid) and writes $\boldsymbol{\beta}$, $\lvert\mu\rvert$ and the patch’s short axis for every grid node into an RGBA32F target; the second draws the attribute-less mesh (DELENS_MESH_VERT / DELENS_MESH_FRAG, vertices built from gl_VertexID) into an RGBA16F accumulator with additive blending; the third (DELENS_RESOLVE_FRAG) divides out the weight onto the canvas. _delensTargets() sizes the grid and creates both targets at the top of _draw(), before the scene’s textures are bound, because _ensureFbo() binds a target’s texture on the active unit and one left on a unit the main program samples would be a feedback loop. _ensureFbo() takes the format (rgba8, rgba16f, rgba32f). The delensSky property carries the picture’s size on the sky, set whether or not the backdrop is hidden, delensMasks the masks as [cx, cy, r, q, φ] (packed into the u_mask / u_maskRot uniform arrays of the mesh pass, at most the exported MAX_MASKS = 16), and delensBright the backdrop’s Brightness, which the resolve pass applies; delensSupported reports whether EXT_color_buffer_float is available. _bgUniforms() returns the pixel-scale framing when bgOpts.sky is set.vizWarp() applies the value→$[0,1]$ warp (linear/sqrt/log/power/asinh) and applyColormap() selects the palette; both are driven by the u_vizScale, u_vizScaleParam, u_vizMin, u_vizMax, and u_colormap uniforms.Renderer class manages the WebGL context, shader compilation, geometry, and texture slots. setScene(planes, dist, fov, viz, vizMode, vizSrcIdx, isDark, saddlePhis, fermatBeta) triggers a redraw, where viz = { scale, param, min, max, palette }.resize() respects a dprMode property ('auto' caps the devicePixelRatio at 2, '1x' forces CSS-pixel resolution, 'native' uses the full ratio), driven by the Render scale setting in the Settings tab.detectorOpts carries the pixel scale and PSF, _detectorPlan() derives the pixel grid and the samples available per pixel (returning applied: false below Renderer.MIN_SAMPLES), _ensureFbo() manages the offscreen targets, _blurInPlace() runs the separable Gaussian, and _runDetectorPasses() bins to the canvas. detectorInfo reports back what was actually done so the UI can raise its warning.MAX_PLANES (8) and MAX_OBJECTS (32 per type) are exported so the UI can enforce the shader’s caps: plane creation is blocked once 8 planes exist (with a toast), and the object count triggers a warning badge past 32 lenses or sources per type.preserveDrawingBuffer: true is set on the WebGL context to allow screenshot and recording capture.main.jsApplication shell (~6400 lines).
state: single object holding all mutable app state: planes and their objects, selected IDs, display flags, add mode, per-viz-mode colour-mapping settings (vizScale: scale, parameter, limits, and palette for each of surface brightness, $\kappa$, $\gamma$, $\lvert\mu\rvert$, $\lvert\hat{\boldsymbol{\alpha}}\rvert$), render scale, recording state.buildDOM(): constructs the entire UI tree in one pass: topbar (with the preset/YAML file cluster and ? shortcut button, plus a mobile-only ⋯ overflow menu whose items forward their clicks to the real controls, so phones keep the bar to one row), the stage holding the image panel and its chrome (view dropdown, overlay chips, zs chip, colorbar, ruler cluster, warning badges; the angular scale bar is drawn on the overlay canvas), the slim redshift timeline with its zmax input (phones only), the scene drawer’s header and empty card row (desktop only; renderDrawer() fills it), the right rail (its desktop category title, the tab bar, the four tab containers, and the persistent plane-card skeleton with its tool column, which is a direct child of the rail rather than of any tab and is shown on phones only), the category switcher (built from INSPECTOR_CATS), and the short-viewport rotate prompt.attachHandlers() for global keyboard/tab/toolbar/file-cluster events; attachAxisHandlers() for plane add/select/drag on the redshift axis; attachPlaneCardHandlers(canvas, { getPlane, swipe }) once for the phone plane card (the plane is resolved at pointerdown, from selectedPlane() by default) and once per drawer card as that card is created (its own plane, which a pointerdown also selects, and no plane swipe); attachDrawerHandlers() for the drawer’s header, its delegated card clicks (the plane actions, a click elsewhere on a card selecting its plane), the category switcher and the placement tool column at the left (#sl-toolcol: L / S / H / M through setCanvasTool(), and delete-object), which updateCanvasTools() keeps lit, with attachSightHandlers() for the line-of-sight strip (hover, click to select, drag to move a plane in z); attachImageHandlers(wrap) for drag-to-move in the main image, which creates objects only on desktop while a placement tool is armed (on the selected plane, at the clicked sky position, with _movePlaceHint() keeping the pointer label beside it and _updatePlaceHint() its text) (on narrow screens the wrap allows vertical page scrolling via touch-action: pan-y, and gestures the app owns opt out with a touchstart preventDefault; a horizontal touch swipe that starts on empty image space cycles the visualization mode through cycleVizMode(), deciding swipe-vs-scroll from the dominant axis of the first 10px of movement; setCanvasTool() picks the plane viewer’s select / lens / source / hybrid tool); attachZoomHandlers(wrap) for wheel zoom and two-finger pinch (both funnel through setFov(), which clamps to [0.5, 300]″ and syncs the View-tab input).PANE_OWN_KEYS (the critical-curve, caustic and marker flags) always belong to each view, and the LINK_GROUPS (zoom, the zs reference, the display toggles, instrument, line art, background framing) do while _link has them unlinked; _paneKeys() lists the keys that are per-view right now. state always holds the focused view’s values, and each pane’s own its copy of the per-view keys: _setFocus() stores the outgoing view’s values and loads the incoming one’s, _withPane(i, fn) swaps a pane’s values (and view) into state while it is drawn, _paneVal() reads a pane’s value without swapping, and setLink() unlinks a group (both views start from the current values) or links it (the focused view’s values win). _renderLink() / _toggleLinkMenu() drive the link button and its menu, and _syncPaneChips() keeps each strip’s toggles lit without rebuilding the strips._panes holds the stage’s one or two panes, each { tabs, active, own } with a tab per view mode, and _focusPane the one last clicked. state.vizMode is always the focused pane’s view, so everything that reads it (the View and Colors panels, the shortcuts, the mask tool) follows the focus; setVizMode() sets it and _reconcilePanes() folds it into the focused pane (activating that view’s tab, or retitling the active tab), which also covers a view set from elsewhere, such as a config load. _withView(mode, fn) runs fn with state.vizMode switched to one pane’s view. renderTabs() builds the tab strips, each ending with that view’s own curve / caustic / marker toggles (the single set of overlay chips on the image is the phones’ version and is hidden on desktop); attachPaneHandlers() wires tab clicks, the + menu and each showing tab’s view menu (_openTabMenu(), _setTabView()), the blank pane that the split button opens (a tab whose view is null, which cannot take the focus until a view is chosen from its _chooserHtml() list), focusing a pane on pointerdown, and tab drag and drop (_moveTab(), _splitWith(); a tab dropped on the right half of a single pane splits). Pane 0 owns the WebGL canvas, its on-image chrome and the exports (captureSnapshot(), buildCompositeCanvas() and _compositeToLive() run under pane 0’s view). Pane 1 is drawn by the same renderer: _doRedraw() renders pane 1’s view first with _renderGL() and copies it into pane 1’s 2D canvas (_copyGLToPane1()), then renders pane 0’s, so the WebGL canvas always ends on pane 0. isSourcePlaneView() (the views in SOURCE_PLANE_VIEWS) puts the source tag on a tab and groups the views in the menus. updateZsChip() sets each pane’s zs chip for that pane’s own view, as _updateColorbar() does for pane 0’s DOM colorbar, so neither follows the focused pane by mistake. drawOverlay() runs _drawOverlayPane() once per pane, swapping overlayCtx and pane 1’s Fermat stationary points in for its pass, and _drawColorbarFixed() draws pane 1’s colorbar at the DOM colorbar’s place. Phones show pane 0 only. Pane 1 has the same drag, ruler and zoom handling as pane 0. A press on a pane’s tab strip does not refocus the pane on pointerdown, since that would rebuild the strip under the pointer and swallow the click; the strip’s own handlers focus it instead.setRailTab(tab) switches the active rail tab (scene | view | export | quality, plus the phone-only object), stamping .sl-body[data-tab] for the responsive CSS. On desktop the tab bar is hidden and the category switcher drives it: INSPECTOR_CATS lists the categories with the tab each one shows, setInspectorCat(cat) selects one, and _syncSwitcher() lights its icon, sets the panel title and stamps #sl-tab-view[data-cat]. The five view categories share the View tab: renderViewPanel() wraps each of its sections in a layout-neutral .sl-cat element naming its category, and the desktop CSS hides the wrappers of the other categories, so the panel stays a single template and phones still see every section in one list. The categories are Object, View, Colors, Background, Instrument, Export and Settings; Colors gathers the color map, the field color (fieldColorSectionHtml()), the annotation ink and the Line art section (lineArtColorsSectionHtml(): the switch, then its options, palette and colors while it is on). _syncSwitcher() also stamps #sl-rail[data-cat], which the desktop CSS uses to drop the boxes, borders and uppercase headings of the panels and, for the object panel and the one-section categories, to use the section’s own header as the panel title. _open(flag) keeps a collapsible section open on desktop, so only phones collapse them. A direct setRailTab() call (the tour, the phone tab bar) falls back to that tab’s first category. |
renderSidebar(): renders the Scene panel (always) plus whichever other tab is active, via renderScenePanel() / renderViewPanel() / renderExportPanel() / renderQualityPanel().renderPlaneCard(): refreshes the selected-plane viewer (the viewer canvas’s plane-type border colour, z input, prev/next arrow states, canvas); selectPlaneOffset() implements the arrow buttons, the [ / ] shortcuts, and the mobile swipe. The viewer’s skeleton is static DOM, so its listeners are wired once. This card and the redshift timeline are the phone controls; on phones the viewer lives in the Scene tab with the timeline below it. Every renderPlaneCard() also calls renderDrawer(), which keeps the desktop scene drawer in step: one card per plane in z order. Each card’s skeleton holds the viewer canvas with its z label and the side column (redshift box, plane type from planeEffectiveType(), plane actions); .sl-dcard-sel shows the side column on the selected card and hides the z label there, so selecting a plane only swaps classes. renderDrawer() ends with renderSightLine(), which redraws the header strip as an SVG: the axis runs to _sightTop(), which is zmax (frozen while a mark is dragged, so the scale does not move under the pointer), and _sightX() / _sightZ() convert between redshift and position. _setSightHot() links a hovered mark and its card. Cards are keyed by plane id in _drawerCards and updated in place rather than rebuilt, because a card’s viewer may be mid-drag when a render comes through; redrawPlaneCanvas() redraws a plane’s drawer viewer as well as the phone one, and _drawerShown() skips drawing while the drawer is tucked away or hidden. _drawerApplyZ() applies a card’s z edit, raising zmax to fit; setZMax() sets zmax from either box (the strip’s and the phone timeline’s), keeping it at or above the farthest plane and both boxes in step. setDrawerOpen() tucks the drawer away and remembers the choice in localStorage. There is no select tool: setCanvasTool() toggles the active L/S/H (or, in the delensed view, M) tool, and with none active (state.addMode === 'select') a click on the canvas only selects and drags.computeThetaE(obj, plane, zs): Einstein-radius helper behind the read-only θE line at the bottom of the lens controls (point mass $b\sqrt{w}$, isothermal $b\,w$, EPL $b\,w^{1/(\gamma’-1)}$ with $w = D_{LS}/D_S$ to the reference source redshift). updateThetaEReadout() refreshes the line in place, without rebuilding the panel, from the parameter sliders, the plane-z input and timeline drag, the zs-ref controls, and the cosmology sliders._doRedraw(): packs the scene into the renderer, redraws the axis canvas, and redraws the overlay (critical curves, position markers, legend, and ruler measurements) on a 2D canvas layered above the WebGL output. Ruler measurements live in state.rulers (arcsec endpoints) and are drawn by drawOverlay(); a ruler drag updates only the overlay, not the GPU scene. In Fermat mode, findStationaryPoints() locates images of the source at $\boldsymbol{\beta}_s$ via a grid search followed by Newton-Raphson refinement, classifies them by Jacobian type, and computes their $\varphi$ values using _computeFullFermat() (a CPU-side mirror of the shader’s comoving arrival-time surface) before passing saddle $\varphi$ levels to the renderer.drawLineArtBase(): the line-art base layer (§9, Line art). When state.lineArt is set, _doRedraw() skips the GPU pass and drawOverlay() calls this first to fill the overlay with the palette background (covering the GL canvas) and draw the uniform-disc lensed-image outlines (computeDiscImageOutlines → chainSegments → smoothPolylines); the normal overlay sections then draw on top, recoloured to the palette, so markers/ruler/scale bar/legend stay visible. Palettes live in LINE_ART_PALETTES (fixed looks, independent of the site theme); selecting one seeds state.lineArtColors (via paletteColors()), which the per-role View-tab pickers then edit and lineArtPalette() resolves. The non-object annotations drawn on top take their color from annotColors() (see below), not from the palette. The image fill is drawn at state.lineArtFillAlpha (the Fill alpha box). buildCompositeCanvas() returns the overlay alone for PNG/recording capture in this mode.annotInk() / annotColors(): the single ink for the non-object annotations (§9, Annotation color). annotAutoInk() is the auto choice (white on a dark page, black on a light one); annotInk() returns it or the pinned state.annotColor, and annotColors() resolves one overlay pass into the ink plus its contrasting halo, pill and marker-backing colors (chosen by hexLuma()), which drawOverlay() and _pillLabel() read in place of the theme test they used before.buildLineArtSVGString() / exportLineArtSVG(): recompute the line-art geometry via the shared lens.js extractors and serialise it to an SVG document (<path> outlines and curves, <circle> image dots), wired to the Export tab’s Save SVG (line art) button; the backing <rect> is emitted only when the adjacent Background toggle (recState.svgBg) is on, and the image fill’s opacity is written as a fill-opacity attribute rather than 8-digit hex, which vector editors handle inconsistently.captureSnapshot() composites the WebGL canvas and overlay into a PNG, reflecting the current view (lensed image or quantity map); startRecording() / stopRecording() drive a MediaRecorder for WebM or a gif.js encoder for GIF. The stretch between stopping and the download is its own state: beginExport() / setExportProgress() / endExport() own recState.encoding, which drives the notice over the image (static DOM in the image wrapper, so it survives a sidebar re-render) and the disabled Record button, and is fed by gif.js’s progress event. It is only entered when there is really an encoder to wait on, so a failed gif.js load cannot leave a notice that never clears. UI chrome (the viz-mode chip, sidebar, ruler buttons, and the performance badge) lives in separate DOM elements and is intentionally excluded from both PNG and recordings. The colour bar is also a DOM element, but _drawColorbarOnto() paints a copy of it (strip and labels) onto the composite so exports include it. The light-mode colour inversion that matches the on-screen lensed image is applied only in surface-brightness mode, since the quantity maps carry their own theming; both capture paths share the glCanvasInverted() test with the CSS rule and with the background image’s pre-inversion (§7).startProgrammaticRecording() interpolates all registered objects simultaneously.pickBackgroundImage() / _applyBackgroundFile() take an uploaded picture and _loadBackgroundFromSite() fetches one a config named, both funnelling into _installBackground(), which decodes it into a size-capped canvas and uploads the texture; clearBackgroundImage() drops it. A monotonic _bgLoadSeq token discards a load still in flight when the next config arrives. isPresetBgName() gates the bgImage key to a bare file name under one fixed directory: that stops a config file from making the browser fetch an arbitrary address, and keeps the texture same-origin, since a cross-origin image taints the canvas and WebGL then refuses to upload it. state.bgSrc records whether the current picture came from a config, which is what lets a load replace a preset image without touching a hand-uploaded one. syncBackdrop(), called at the top of _doRedraw(), pushes the framing controls into renderer.bgOpts, or null while the eye toggle hides it. glCanvasInverted() is the single source of truth for whether the GL canvas is CSS-inverted right now: it drives the light-mode inversion applied by PNG capture and recording, the invert flag that makes the shader pre-invert the backdrop so it survives that inversion unchanged, and the complement shown in the Field color picker. _hexRgb01() converts the stored field colour for the uniform; _borderAverageHex() and _matchablePastedCanvas() back the Match image button, averaging the border ring of the selected pasted image (or the first one in the scene) and returning null for a canvas that cannot be read back. bgImageActive() reports whether a backdrop is actually on screen (loaded, not hidden, lensed-image view, not line art) and only drives the section’s explanatory note. The pixel scale (state.bgPixel, per pixel of the image as supplied, whose size _installBackground() records as bgNatW / bgNatH) is turned into the picture’s size on the sky by bgSkySize(), which syncBackdrop() hands to the renderer both as bgOpts.sky and as delensSky; seedBgPixel() gives a picture without one the scale _bgImpliedPixel() derives from its as-loaded framing, and is called on entering the delensed view, when an image arrives while it is open, and after a config loads into it. delensSectionHtml() builds the View tab’s Delensed Image section and _refreshDelensReadout() keeps its readout, and the Background section’s note, live under a scrub. captureSnapshot() calls syncBackdrop() itself, so a still taken before the next frame still carries the current masks and brightness.state.multiSel holds the ids of a multi-selection (a hybrid by its lens half) and counts only while it contains the primary state.selectedObjId, so any code that selects something on its own returns to a single selection without having to know about it. selectionIds(), isObjSelected() (which drawOverlay() and drawPlaneCanvas() use for the selection rings) and selectionEntries() read it; selectSingle() and toggleSelected() (Cmd/Ctrl+click) set it. A press on a member starts a group move: _groupStart() records every selected object’s start and _moveGroup() offsets them all, partners included, in both attachImageHandlers() and attachPlaneCardHandlers(); a press without a drag collapses to that object. A mouse drag on empty image space is a box select: _objectsInRect() gathers what _grabbable() allows in that view, and _drawMarquee() draws the box on the pane it is dragged in. Only positions move together; the Object panel edits the primary, and notes when several are selected.'mask' (model 'mask', params r, q, phi) living on planes like any other, so selection, dragging, hiding, copy/paste, undo, programmatic animation and the config file all carry them; the renderer, the tracer and every lens/source filter skip them by type, and computeCritCurvesForZs() leaves them out of its cache key so that dragging one does not recompute the curves. activeMasks() collects the visible ones for syncBackdrop(), masksShown() keeps their markers and hit-testing in every view but line art, and maskToolAvailable() offers the mask tool and the 4 key while some tab shows the delensed view (on phones, while it is showing); updateCanvasTools() hides the button and disarms the tool when that stops being so. The tool buttons draw their icons from TOOL_ICON_PATHS (toolIcon()). The Object tab gives them their own branch (Size, q, φ; no model choice, MASK_INFO), drawShapeMarker() draws them as circles, and typeColorHex('mask') matches the --mask-color CSS variable.INSTRUMENTS table holds each detector’s pixel scale and representative PSF width; instrumentSpec() resolves the selection (including Custom) and detectorStats() derives the field geometry — pixels across the field, samples per pixel, and the widest field the current grid can bin — which drives both the section’s live readout (instrReadoutHtml()) and the warning pill (reportDetector()). syncDetector() pushes the result into renderer.detectorOpts from _doRedraw(), alongside syncBackdrop().configToYaml() serialises all planes and objects, plus the view state needed to reproduce the rendered image (field of view, $z_\text{max}$, active viz mode, per-mode color mapping, contour spacing, image-number grid spacing and number size, critical-curve resolution and FOV buffer, point-source grid density, render scale, the critical-curve redshift, the marker/legend/legend-background/colorbar/critical-curve/caustic toggles, the curve-type display and stretch toggle, the line-art mode settings (palette, per-role colors, fill alpha, and the fill / smooth toggles), the annotation-ink mode and color, the background-image framing, field colour and hide toggle, and the instrument detector model), to a human-readable YAML string. parseYamlConfig() parses it back with strict type and range validation (allowlisted model names, hex-only color strings, bounded numeric coordinates), and the loader migrates the legacy point-mass thetaE key to its current name b. The scalar view settings are centralised in a CONFIG_DEFAULTS table that both seeds the initial state and supplies the fallback for any field missing from a loaded file, so older or partial configs load to a fully defined visual state rather than inheriting whatever was on screen. The page theme is not part of the config (it is a site-wide preference), nor are pasted-image textures or an uploaded background picture (binary data); images shipped with the site are carried by file name instead, via the validated bgImage key for the backdrop (§7) and a per-object image: key for pasted-image sources, whose textures _loadPastedFromSite() fetches after the planes are built. Both names go through isPresetBgName(), and a _pastedLoadGen generation counter (the per-object analogue of _bgLoadSeq) drops images still in flight when the next config arrives.planesForZs() returns the plane list and index for tracing to the reference $z_s$ (adding a virtual plane if needed) and backs both computeCritCurvesForZs() and computeStretchField(), which builds the whisker grid (§4). splitCurveTypes() divides segments by their radial flags for drawOverlay() and the SVG export; radial ones are chained with chainSegments() before being stroked dotted, so the dot spacing is continuous.viewCritCurves() computes the curves over $\max(1.3\times\text{fov}, \text{FOV buffer})$. The overlay, the image-number map, and the SVG export all use it, and computeCritCurvesForZs() caches the last few results.imageNumberCounts() gives each grid point $n_0 + 2k$, with $k$ from winding numbers about the closed caustic loops, and caches the result. $n_0$ comes from the point-source solver, split into imageSearchGrid() (the traced image-plane grid, which doesn’t depend on the source) and solveImages() (per source), so the four anchor solves share one grid. drawImageNumberGrid() writes the counts in IMAGE_NUMBER_COLORS. In this view drawOverlay() hides image-plane features (_srcPlaneView, which the delensed view shares) and sources (_imgNumView), and hitTestImage() skips sources.drawOverlay() caches its last computed critical-curve/caustic segments (state._lastCurves) and point-source image positions (state._lastPsImages); the Export tab’s Data section serialises these via _downloadCSV().startTour() / showTourStep(): spotlight-and-tooltip tutorial whose step callbacks switch rail tabs (setRailTab) as needed. Steps marked desktopOnly (the drawer) or mobileOnly (the timeline, the plane viewer, the swipe hints) drop out at the other width, and mobileTarget / mobileText give a step a different target or text on phones (the category icons on desktop, the tab bar on phones). A one-time nudge bubble on the Tour button invites first-time visitors.toggleKbdOverlay() shows the in-app keyboard reference (the topbar Shortcuts button, which reads ? on narrow windows, or the ? key).style.cssSingle stylesheet using CSS custom properties for theming (--accent, --lens-color, --src-color, --hybrid-color, and others) with html[data-theme="dark"] overrides. The layout is a CSS grid. From 641px up its areas are the stage (which letterboxes the square image using container-query units) over the scene drawer on the left, then the rail, then the category switcher on the far right; the timeline, the rail’s tab bar and the plane card are hidden. In the stage, .sl-panes lays out one or two .sl-panes, each a tab strip over an image wrapper sized from container-query units (half the width each when .sl-split is set). The tabs are folder tabs: a pane’s frame colour and width (--fc, --fw; teal only while a tab is dragged onto the pane) outline both its image and its showing tab, and the focused pane’s showing tab carries a teal line along its top (.sl-pane-focus), which overhangs the frame’s top edge by the border width so the two read as one shape. The frame is drawn by an ::after overlay above the canvas rather than as the wrapper’s border, since a canvas clipped to a border’s inner curve eats into the line at the rounded corners. The placement tool column has a hairline on its right. The link button and its menu are placed halfway down the gap between the panes with the same container-query arithmetic; on phones the pane wrappers are display: contents, so the image wrapper lays out as before, and the tab strips, the second pane and the drop zone are hidden. The drawer’s cards keep a fixed size (the selected one wider, with its tool and side columns) and scroll sideways when they outgrow the drawer. Narrow screens (≤ 640px) collapse to a single scrolling column: image, then the rail, with a mobile-only Object tab hosting the object controls and the plane viewer moving into the Scene tab above the redshift timeline. There the viewer’s tools become a single column of larger tap targets (L / S / H, and M in the delensed view, at the top, a trash button floated to the bottom), the ‹ › arrows hug the canvas, and the tool column and the Redshift / Plane column stretch to the canvas height and sit an even gap from it, so the four control clusters frame the canvas at its corners. Short landscape viewports (height ≤ 480px, e.g. a phone held sideways) show a full-screen prompt asking for a taller window instead of the app.
gif.js + gif.worker.jsA vendored local copy of the gif.js library. gif.worker.js carries one local patch: findClosestRGB memoises its lookups, since with the shared palette from _renderGif() in main.js it would otherwise scan all 256 entries for every pixel. Loaded lazily (only when GIF recording is requested) via a dynamic <script> tag, avoiding cross-origin Web Worker restrictions that would arise from a CDN-hosted copy.