clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / internal / app / partials.go
partials.go 7.0K
1package app23import (4 "bytes"5 "fmt"6 "log/slog"7 "net/http"89 "github.com/FelineStateMachine/atlas/internal/app/templates"10 "github.com/FelineStateMachine/atlas/internal/logging"11)1213// The partial envelope. Every interaction answers with an <hx-partial> set14// covering exactly the regions it touched (issue #5 §4.2) -- a legend row, a15// solo chip, a dock readout and the viewport's state node in one response,16// rather than a page-wide refresh standing in for knowing what moved.17//18// The envelope is what the handler, the templates and the seam agree on, and19// it is stable ahead of the templates that will fill it: a region has one20// element id and one swap, named here and nowhere else. Two swaps are21// deliberate and load-bearing:22//23// - morph swaps for legend, dock and detail, so scroll position, focus and24// open <details> survive a re-render (issue #5 §4.3). They are *outer*25// morphs, and that is not a style choice: a region's template renders the26// region's own container (§4.1), so the answer to a swap always carries an27// element with the target's own id. Asking htmx to morph that into the28// target's *children* asks it to make an element a descendant of itself,29// which it declines -- silently, leaving the page as it was, which is a30// legend that never recounts and a dock that never reloses a row. Morphing31// the element onto itself is the reading that matches what the templates32// actually render, and it keeps the first-paint bytes and the swap bytes33// identical, which is the property §4.1 wanted in the first place;34// - the viewport's state node is swapped whole and the viewport's own35// internals are never touched -- the seam owns what is inside it, and a36// swap that reached in would tear down a WebGL context mid-gesture. The37// custom elements carry hx-morph-skip-children, which is htmx 4's own38// spelling of that rule, so the guarantee is the runtime's rather than a39// convention every template has to remember.40//41// The island is a region for one reason and it is worth stating: the camera.42// Every other key it publishes is decided by the request that is being43// answered, so re-rendering the island with that answer would be enough. The44// camera is not -- it arrives on its own route, from the seam, after the view45// has settled, and nothing else is going to happen afterwards to carry it46// back. Making it a region is what lets the one concern that answers nothing47// visible still answer with the record it just wrote.48//49// The envelope spells its target and swap as hx-target and hx-swap rather50// than as bare target and swap attributes: htmx 4 reads an <hx-partial>51// through the same attribute vocabulary it reads everything else through, and52// an unprefixed target is silently no target at all. The region names and the53// element ids either side of it are unchanged.5455// A partialTarget is where one region's HTML goes and how it lands.56type partialTarget struct {57 target string58 swap string5960 // template names the template to render when it is not the region's own61 // name. Only the shell needs it: what a first paint answers with is a62 // whole document, and what a swap answers with is the frame inside it.63 template string64}6566// partialTargets is the region map. A region absent from this table cannot be67// swapped, which is the point: the set of things an interaction may move is68// declared, not discovered.69var partialTargets = map[string]partialTarget{70 // The shell renders a whole document on a first paint and its own frame71 // on a swap, which is why it names a template of its own. Morphing the72 // element onto itself is what lets a swap set the class the sidebar's73 // collapsed state is spelled as -- a container cannot be given an74 // attribute by a swap that renders only its inside.75 "shell": {target: "#atlas-shell", swap: "outerMorph", template: "shell-region"},76 "topbar": {target: "#atlas-topbar", swap: "innerMorph"},77 "legend": {target: "#atlas-legend", swap: "outerMorph"},78 "dock": {target: "#atlas-dock", swap: "outerMorph"},79 "detail": {target: "#atlas-detail", swap: "outerMorph"},80 "grid-navigator": {target: "#atlas-grid-navigator", swap: "outerMorph"},81 "overview": {target: "#atlas-overview", swap: "outerMorph"},82 "viewport": {target: "#atlas-viewport-state", swap: "outerMorph"},83 // Replaced rather than morphed: it is a script node with nothing inside84 // worth preserving, and morphing one leaves its text where it was.85 "island": {target: "#atlas-session-island", swap: "outerHTML"},86 "empty-state": {target: "#atlas-shell", swap: "innerMorph"},87 // One import is one row, and the row is this region's whole content: an88 // import answers with the region rendered again for every state it89 // reaches, and the morph carries the row from "choosing a bundle" to90 // "installed" in place. It is an *outer* morph for the reason every other91 // morph here is one -- the template renders the region's own container --92 // and the container is what makes a run that came to nothing possible to93 // undo: the last render of a cancelled import holds no row at all.94 //95 // It used to be a beforeend append, which made every state of every run a96 // line of its own, immortal until the next full page render, and a97 // cancelled picker two of them (issue #5 §4.2).98 "import": {target: "#atlas-import", swap: "outerMorph"},99}100101// renderPartials builds the <hx-partial> set for a list of regions.102func renderPartials(regions []string, data any) ([]byte, error) {103 var out bytes.Buffer104 for _, region := range regions {105 where, known := partialTargets[region]106 if !known {107 return nil, fmt.Errorf("region %q has no partial target", region)108 }109 fmt.Fprintf(&out, "<hx-partial hx-target=%q hx-swap=%q>", where.target, where.swap)110 name := region111 if where.template != "" {112 name = where.template113 }114 if err := templates.Render(&out, name, data); err != nil {115 return nil, err116 }117 out.WriteString("</hx-partial>\n")118 }119 return out.Bytes(), nil120}121122// writePartials answers a request with a partial set. A template that fails to123// render is a programming error the reader cannot act on, so it is logged and124// answered as one rather than pasted into the page.125func (a *App) writePartials(w http.ResponseWriter, regions []string, data any) {126 body, err := renderPartials(regions, data)127 if err != nil {128 slog.Error("rendering partials", logging.Op("render"), slog.Any("error", err))129 http.Error(w, "the response could not be rendered", http.StatusInternalServerError)130 return131 }132 w.Header().Set("Content-Type", "text/html; charset=utf-8")133 _, _ = w.Write(body)134}135136// writePage answers with a whole page.137func (a *App) writePage(w http.ResponseWriter, region string, data any) {138 var out bytes.Buffer139 if err := templates.Render(&out, region, data); err != nil {140 slog.Error("rendering a page", logging.Op("render"),141 slog.String("region", region), slog.Any("error", err))142 http.Error(w, "the page could not be rendered", http.StatusInternalServerError)143 return144 }145 w.Header().Set("Content-Type", "text/html; charset=utf-8")146 _, _ = w.Write(out.Bytes())147}