Add map section with geocoded family pins
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -6,18 +6,20 @@ import (
|
||||
"time"
|
||||
|
||||
"heliosian/internal/data"
|
||||
"heliosian/internal/geocode"
|
||||
)
|
||||
|
||||
const refreshInterval = 5 * time.Minute
|
||||
|
||||
type Cache struct {
|
||||
source data.Source
|
||||
mu sync.RWMutex
|
||||
model *Model
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -23,11 +23,12 @@ var legacy = map[string]string{
|
||||
}
|
||||
|
||||
type app struct {
|
||||
cache *Cache
|
||||
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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user