Add map section with geocoded family pins

This commit is contained in:
Ian Gulliver
2026-08-15 23:21:03 -07:00
parent c56310d219
commit fd52c948db
11 changed files with 350 additions and 13 deletions
+7
View File
@@ -14,6 +14,13 @@ Everything — pages, static assets, and the API — sits behind Google sign-in
To capture authenticated pages with the screenshot tooling, launch the capture browser (`go run ./tools/capturebrowser`), sign in to the local server there once, and use `tools/browse` or `tools/screenshot -remote` — the session cookie lives in the capture profile. Plain `tools/screenshot` runs a fresh headless browser with no session and captures the login page.
## Maps
The map section geocodes family addresses server-side via the Google Geocoding API (results cached in memory per address) and renders in the browser with the Maps JavaScript API. Two API keys from a project with those APIs enabled, both required at startup:
- Server key — Geocoding API; restrict by server IP (or leave unrestricted for dev). Never rendered into pages. Read from `creds/geocoding.key`, or `GOOGLE_MAPS_SERVER_KEY` when set.
- Browser key — Maps JavaScript API; rendered into the page, so restrict by HTTP referer (localhost and the serving domain). Read from `creds/maps.key`, or `GOOGLE_MAPS_BROWSER_KEY` when set.
## Local data
The server reads local data from `sampledata/`, mirroring the production Sheets layout: one directory per app, one CSV file per table, first row is the schema. It goes through the same data-source interface production backends implement, so app code never knows which backend it is talking to.
+1 -1
View File
@@ -49,7 +49,7 @@ The staff list as a top-level section — same content as the People staff tab.
### Map
A map of family locations, plus an "update my address" self-service action.
A Google map of family locations: one brand-teal pin per geocoded family address, a popup card (family photo, name, address, family-page link) on pin click, and search and filters narrowing the pins. Below the map, an "update my address" self-service action.
### Email List
+1 -1
View File
@@ -4,7 +4,7 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da
## Directory app
- Remaining sections: My Family, Map.
- Remaining sections: My Family.
- Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful.
- Installable-app plumbing: manifest, icons, and meta tags per `docs/pwa.md`.
- Self-service flows: photo and pronunciation upload, address update, opt-out.
+47 -2
View File
@@ -6,18 +6,20 @@ import (
"time"
"heliosian/internal/data"
"heliosian/internal/geocode"
)
const refreshInterval = 5 * time.Minute
type Cache struct {
source data.Source
geocoder *geocode.Client
mu sync.RWMutex
model *Model
}
func NewCache(source data.Source) (*Cache, error) {
c := &Cache{source: source}
func NewCache(source data.Source, geocoder *geocode.Client) (*Cache, error) {
c := &Cache{source: source, geocoder: geocoder}
if err := c.refresh(); err != nil {
return nil, err
}
@@ -45,6 +47,7 @@ func (c *Cache) refresh() error {
if err != nil {
return err
}
c.geocodeFamilies(model)
c.mu.Lock()
c.model = model
c.mu.Unlock()
@@ -53,3 +56,45 @@ func (c *Cache) refresh() error {
time.Since(start).Round(time.Millisecond))
return nil
}
func (c *Cache) geocodeFamilies(model *Model) {
start := time.Now()
type job struct {
key string
address string
}
jobs := make(chan job)
var wg sync.WaitGroup
var mu sync.Mutex
located := 0
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
point, err := c.geocoder.Lookup(j.address)
if err != nil {
log.Printf("[ERROR] %v", err)
continue
}
mu.Lock()
family := model.Families[j.key]
family.Lat = point.Lat
family.Lng = point.Lng
model.Families[j.key] = family
located++
mu.Unlock()
}
}()
}
total := 0
for key, family := range model.Families {
if family.Address != "" {
total++
jobs <- job{key: key, address: family.Address}
}
}
close(jobs)
wg.Wait()
log.Printf("geocoded %d of %d family addresses in %s", located, total, time.Since(start).Round(time.Millisecond))
}
+4 -2
View File
@@ -24,10 +24,11 @@ var legacy = map[string]string{
type app struct {
cache *Cache
mapsKey string
}
func Register(mux *http.ServeMux, cache *Cache) {
a := app{cache: cache}
func Register(mux *http.ServeMux, cache *Cache, mapsKey string) {
a := app{cache: cache, mapsKey: mapsKey}
for _, section := range sections {
mux.HandleFunc("GET /"+section, a.page)
}
@@ -60,6 +61,7 @@ func (a app) page(w http.ResponseWriter, r *http.Request) {
"UserName": name,
"UserInitial": strings.ToUpper(name[:1]),
"UserEmail": auth.Email(r),
"MapsKey": a.mapsKey,
}
if err := t.Execute(w, data); err != nil {
log.Printf("[ERROR] render directory page: %v", err)
+2
View File
@@ -28,6 +28,8 @@ type Family struct {
Key string `json:"key"`
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Lat float64 `json:"lat,omitempty"`
Lng float64 `json:"lng,omitempty"`
PhotoURL string `json:"photoUrl,omitempty"`
PhotoCaption string `json:"photoCaption,omitempty"`
PronunciationURL string `json:"pronunciationUrl,omitempty"`
+62
View File
@@ -0,0 +1,62 @@
// Package geocode resolves street addresses to coordinates via the google geocoding api.
package geocode
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"sync"
)
type Point struct {
Lat float64
Lng float64
}
type Client struct {
key string
mu sync.Mutex
cache map[string]Point
}
func New(key string) *Client {
return &Client{key: key, cache: map[string]Point{}}
}
func (c *Client) Lookup(address string) (Point, error) {
c.mu.Lock()
point, ok := c.cache[address]
c.mu.Unlock()
if ok {
return point, nil
}
resp, err := http.Get("https://maps.googleapis.com/maps/api/geocode/json?address=" +
url.QueryEscape(address) + "&key=" + url.QueryEscape(c.key))
if err != nil {
return Point{}, err
}
defer resp.Body.Close()
var parsed struct {
Status string `json:"status"`
Results []struct {
Geometry struct {
Location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"location"`
} `json:"geometry"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return Point{}, err
}
if parsed.Status != "OK" || len(parsed.Results) == 0 {
return Point{}, fmt.Errorf("geocode %q: %s", address, parsed.Status)
}
point = Point{Lat: parsed.Results[0].Geometry.Location.Lat, Lng: parsed.Results[0].Geometry.Location.Lng}
c.mu.Lock()
c.cache[address] = point
c.mu.Unlock()
return point, nil
}
+21 -2
View File
@@ -7,11 +7,13 @@ import (
"log"
"net/http"
"os"
"strings"
"heliosian/internal/auth"
"heliosian/internal/blob"
"heliosian/internal/data"
"heliosian/internal/directory"
"heliosian/internal/geocode"
)
func directorySource() data.Source {
@@ -64,15 +66,32 @@ func noCache(next http.Handler) http.Handler {
})
}
func mapsKey(envName, file string) string {
if key := os.Getenv(envName); key != "" {
return key
}
raw, err := os.ReadFile(file)
if err != nil {
log.Fatalf("[ERROR] read %s (or set %s): %v", file, envName, err)
}
key := strings.TrimSpace(string(raw))
if key == "" {
log.Fatalf("[ERROR] %s is empty", file)
}
return key
}
func main() {
authn := auth.New(clientID(), sessionKey())
cache, err := directory.NewCache(directorySource())
serverKey := mapsKey("GOOGLE_MAPS_SERVER_KEY", "creds/geocoding.key")
browserKey := mapsKey("GOOGLE_MAPS_BROWSER_KEY", "creds/maps.key")
cache, err := directory.NewCache(directorySource(), geocode.New(serverKey))
if err != nil {
log.Fatalf("[ERROR] load directory data: %v", err)
}
mux := http.NewServeMux()
authn.Register(mux)
directory.Register(mux, cache)
directory.Register(mux, cache, browserKey)
if os.Getenv("DIRECTORY_SHEET") != "" {
store, err := blob.New()
if err != nil {
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/static/fonts/fonts.css">
<link rel="stylesheet" href="/static/directory/style.css">
</head>
<body data-user-email="{{.UserEmail}}">
<body data-user-email="{{.UserEmail}}" data-maps-key="{{.MapsKey}}">
<aside class="sidebar">
<div class="brand"><img src="/static/brand/icon-192.png" alt=""><span>Helios Who?</span></div>
<nav id="nav"></nav>
+135
View File
@@ -32,6 +32,7 @@ const icons = {
copy: '<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>',
message: '<svg viewBox="0 0 24 24"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>',
phone: '<svg viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>',
zap: '<svg viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>',
};
const navSections = [
@@ -1371,6 +1372,137 @@ function renderEmailListPage() {
input.focus();
}
let mapsPromise = null;
function loadMaps() {
if (!mapsPromise) {
mapsPromise = new Promise(resolve => {
window._mapsReady = resolve;
const script = el('script');
script.src = 'https://maps.googleapis.com/maps/api/js?key=' +
encodeURIComponent(document.body.dataset.mapsKey) + '&callback=_mapsReady';
script.async = true;
document.head.append(script);
});
}
return mapsPromise;
}
const pinIcon = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="34" height="34">' +
'<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" fill="#173c41" stroke="#fff" stroke-width="1"/>' +
'<circle cx="12" cy="10" r="3" fill="#fff"/></svg>');
function renderMapPage() {
const main = document.querySelector('#main');
main.replaceChildren();
const content = el('div', 'content container');
const header = el('div', 'content-header');
header.append(el('h1', '', 'Map'));
const controls = el('div', 'controls');
const search = el('div', 'search');
search.append(svg('search'));
const input = el('input');
input.placeholder = 'Search';
input.value = state.q;
input.addEventListener('input', () => {
state.q = input.value.trim().toLowerCase();
renderPins();
});
search.append(input);
controls.append(search, filterControl(() => renderPins()));
header.append(controls);
content.append(header);
const canvas = el('div', 'map-canvas');
content.append(canvas);
const update = el('div', 'map-update');
const action = el('a', 'map-update-link');
action.append(svg('zap'), el('span', '', 'Update My Address'));
update.append(action);
content.append(update);
main.append(content);
let map = null;
let info = null;
let markers = [];
function familySearchText(family) {
const members = [...(family.kidEmails || []), ...(family.adultEmails || [])]
.map(e => byEmail[e]).filter(Boolean).map(p => p.fullName);
return `${family.name || ''} ${members.join(' ')}`.toLowerCase();
}
function popupContent(family) {
const box = el('div', 'map-popup');
if (family.photoUrl) {
const img = el('img', 'map-popup-photo');
img.src = thumbUrl(family.photoUrl);
img.alt = '';
box.append(img);
}
box.append(el('div', 'map-popup-name', family.name));
if (family.address) {
box.append(el('div', 'map-popup-sub', family.address));
}
const link = el('a', 'map-popup-link', 'See family');
link.href = '/families/' + encodeURIComponent(family.key);
box.append(link);
return box;
}
function renderPins() {
if (!map) {
return;
}
for (const m of markers) {
m.setMap(null);
}
markers = [];
for (const family of Object.values(state.model.families)) {
if (!family.lat && !family.lng) {
continue;
}
if (!familyMatchesFilters(family.key) || !familySearchText(family).includes(state.q)) {
continue;
}
const marker = new google.maps.Marker({
map,
position: {lat: family.lat, lng: family.lng},
icon: {url: pinIcon, anchor: new google.maps.Point(17, 33)},
title: family.name,
});
marker.addListener('click', () => {
info.setContent(popupContent(family));
info.open(map, marker);
});
markers.push(marker);
}
}
loadMaps().then(() => {
if (!canvas.isConnected) {
return;
}
map = new google.maps.Map(canvas, {
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
});
info = new google.maps.InfoWindow();
const bounds = new google.maps.LatLngBounds();
for (const family of Object.values(state.model.families)) {
if (family.lat || family.lng) {
bounds.extend({lat: family.lat, lng: family.lng});
}
}
map.fitBounds(bounds);
renderPins();
});
}
function renderProfile() {
const main = document.querySelector('#main');
main.replaceChildren();
@@ -1425,6 +1557,9 @@ function render() {
state.emailTab = tabParam('parents');
state.q = '';
renderEmailListPage();
} else if (seg[0] === 'map') {
state.q = '';
renderMapPage();
} else if (seg[0] === 'profile') {
renderProfile();
}
+65
View File
@@ -388,6 +388,71 @@ main {
color: #fff;
}
.map-canvas {
height: 600px;
border-radius: 12px;
background: #eef1f3;
}
.map-update {
display: flex;
justify-content: center;
margin-top: 26px;
}
.map-update-link {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink);
font-size: 15px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.map-update-link svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.map-popup {
max-width: 220px;
font-family: Inter, system-ui, sans-serif;
}
.map-popup-photo {
width: 100%;
border-radius: 8px;
display: block;
margin-bottom: 8px;
}
.map-popup-name {
font-weight: 700;
font-size: 15px;
}
.map-popup-sub {
color: var(--muted);
font-size: 13px;
margin-top: 3px;
}
.map-popup-link {
display: inline-block;
margin-top: 8px;
color: var(--brand);
font-weight: 600;
font-size: 13px;
text-decoration: none;
}
.email-hint {
color: var(--muted);
font-size: 14px;