Skip to content

Commit 0894159

Browse files
authored
fix: serve map sprites globally instead of per-map duplicates (#285)
* fix: serve map sprites globally instead of per-map duplicates Sprites (icon sheets for MapLibre vector styles) were generated per-map and stored in each map's styles/ directory. The content is identical for every map since it comes from embedded icons. This caused wasted disk I/O during import/restyle and made sprite URLs per-map when they should be global (like fonts already are). Backend: - Add GenerateSpriteBytes() for in-memory sprite generation - Add GET /images/maps/sprites/:name handler with sync.Once lazy init - Remove WriteSpriteFiles calls from pipeline and restyle stages - Remove StylesPrefix from StyleConfig (only used for sprites) - Style JSON sprite path now "images/maps/sprites/sprite" (global) Frontend: - Replace transformStyle with fetchStyle that fetches style JSON and patches sprite/glyphs URLs to absolute before passing to MapLibre. leaflet-maplibre-gl doesn't forward transformStyle to the underlying Map constructor's setStyle call, so it never actually ran. * fix: surface sprite generation errors instead of swallowing them
1 parent 0eb58a7 commit 0894159

File tree

11 files changed

+139
-40
lines changed

11 files changed

+139
-40
lines changed

internal/maptool/metadata.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,9 @@ func NewGenerateStylesStage() Stage {
104104

105105
mapBase := "images/maps/" + worldName
106106
tilesPrefix := mapBase
107-
stylesPrefix := mapBase
108107
stylesDir := job.OutputDir
109108
if job.SubDirs {
110109
tilesPrefix = mapBase + "/tiles"
111-
stylesPrefix = mapBase + "/styles"
112110
stylesDir = job.StylesOutputDir()
113111
if err := os.MkdirAll(stylesDir, 0755); err != nil {
114112
return fmt.Errorf("create styles dir: %w", err)
@@ -118,7 +116,6 @@ func NewGenerateStylesStage() Stage {
118116
styleCfg := StyleConfig{
119117
WorldName: worldName,
120118
URLPrefix: tilesPrefix,
121-
StylesPrefix: stylesPrefix,
122119
VectorLayers: job.VectorLayers,
123120
HasSatellite: true,
124121
HasHeightmap: job.HasHeightmap,
@@ -146,10 +143,6 @@ func NewGenerateStylesStage() Stage {
146143
}
147144
job.HasMaplibre = true
148145

149-
if err := WriteSpriteFiles(stylesDir); err != nil {
150-
return fmt.Errorf("write sprites: %w", err)
151-
}
152-
153146
return nil
154147
},
155148
}

internal/maptool/metadata_test.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,6 @@ func TestNewGenerateStylesStage(t *testing.T) {
136136
_, err := os.Stat(filepath.Join(dir, name))
137137
assert.NoError(t, err, "expected %s to exist", name)
138138
}
139-
// Verify sprites exist
140-
_, err = os.Stat(filepath.Join(dir, "sprite.json"))
141-
assert.NoError(t, err)
142139
}
143140

144141
func TestNewGenerateStylesStage_SubDirs(t *testing.T) {

internal/maptool/restyle.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ func RestyleWorld(mapsDir, worldName string) error {
4343
styleCfg := StyleConfig{
4444
WorldName: meta.WorldName,
4545
URLPrefix: mapBase + "/tiles",
46-
StylesPrefix: mapBase + "/styles",
4746
VectorLayers: meta.FeatureLayers,
4847
HasSatellite: hasFile("satellite.pmtiles"),
4948
HasHeightmap: hasFile("heightmap.pmtiles"),
@@ -80,10 +79,5 @@ func RestyleWorld(mapsDir, worldName string) error {
8079
}
8180
}
8281

83-
// 5. Regenerate sprites
84-
if err := WriteSpriteFiles(stylesDir); err != nil {
85-
return fmt.Errorf("write sprites: %w", err)
86-
}
87-
8882
return nil
8983
}

internal/maptool/restyle_test.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,4 @@ func TestRestyleWorld_ProbesTileFiles(t *testing.T) {
9494
require.NoError(t, err, "expected %s to exist", name)
9595
assert.Greater(t, info.Size(), int64(0))
9696
}
97-
98-
// Should have generated sprites
99-
for _, name := range []string{"sprite.json", "sprite.png", "sprite-dark.json", "sprite-dark.png"} {
100-
_, err := os.Stat(filepath.Join(worldDir, "styles", name))
101-
assert.NoError(t, err, "expected %s to exist", name)
102-
}
10397
}

internal/maptool/sprite.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,34 @@ func GenerateSprite(blacken bool) (*image.NRGBA, map[string]spriteEntry) {
143143
return sheet, manifest
144144
}
145145

146+
// GenerateSpriteBytes returns all sprite files as in-memory byte slices.
147+
// Keys: "sprite.json", "sprite.png", "sprite-dark.json", "sprite-dark.png".
148+
func GenerateSpriteBytes() (map[string][]byte, error) {
149+
result := make(map[string][]byte, 4)
150+
for _, v := range []struct {
151+
prefix string
152+
blacken bool
153+
}{
154+
{"sprite", true},
155+
{"sprite-dark", false},
156+
} {
157+
img, manifest := GenerateSprite(v.blacken)
158+
159+
data, err := json.MarshalIndent(manifest, "", " ")
160+
if err != nil {
161+
return nil, fmt.Errorf("marshal %s.json: %w", v.prefix, err)
162+
}
163+
result[v.prefix+".json"] = data
164+
165+
var buf bytes.Buffer
166+
if err := png.Encode(&buf, img); err != nil {
167+
return nil, fmt.Errorf("encode %s.png: %w", v.prefix, err)
168+
}
169+
result[v.prefix+".png"] = buf.Bytes()
170+
}
171+
return result, nil
172+
}
173+
146174
// WriteSpriteFiles generates sprite sheets at native 64px and writes to dir:
147175
// - sprite.json, sprite.png (blackened — for light themes)
148176
// - sprite-dark.json, sprite-dark.png (original — for dark themes)

internal/maptool/styles.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,7 +1157,6 @@ const (
11571157
type StyleConfig struct {
11581158
WorldName string
11591159
URLPrefix string // e.g. "images/maps/stratis/tiles" — base for tile sources
1160-
StylesPrefix string // e.g. "images/maps/stratis/styles" — base for sprite files
11611160
VectorLayers []string
11621161
HasSatellite bool
11631162
HasHeightmap bool
@@ -1214,7 +1213,7 @@ func GenerateStyleDocument(cfg StyleConfig, variant StyleVariant) map[string]int
12141213
"name": cfg.WorldName + "-" + string(variant),
12151214
"sources": sources,
12161215
"layers": layers,
1217-
"sprite": assetPath(cfg.StylesPrefix, spriteName),
1216+
"sprite": "images/maps/sprites/" + spriteName,
12181217
"glyphs": cfg.GlyphsURL,
12191218
}
12201219
return doc

internal/maptool/styles_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,6 @@ func TestGenerateStyleDocument_Structure(t *testing.T) {
203203
cfg := StyleConfig{
204204
WorldName: "altis",
205205
URLPrefix: "images/maps/altis/tiles",
206-
StylesPrefix: "images/maps/altis/styles",
207206
VectorLayers: []string{"sea", "road", "namecity"},
208207
HasSatellite: true,
209208
GlyphsURL: "images/maps/fonts/{fontstack}/{range}.pbf",
@@ -215,7 +214,7 @@ func TestGenerateStyleDocument_Structure(t *testing.T) {
215214
assert.Equal(t, "altis-color-relief", doc["name"])
216215
assert.NotNil(t, doc["sources"])
217216
assert.NotNil(t, doc["layers"])
218-
assert.Equal(t, "images/maps/altis/styles/sprite", doc["sprite"])
217+
assert.Equal(t, "images/maps/sprites/sprite", doc["sprite"])
219218
assert.Equal(t, "images/maps/fonts/{fontstack}/{range}.pbf", doc["glyphs"])
220219
}
221220

internal/server/handler.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"path/filepath"
1616
"strconv"
1717
"strings"
18+
"sync"
1819
"time"
1920

2021
"github.com/OCAP2/web/internal/maptool"
@@ -50,6 +51,10 @@ type Handler struct {
5051
openIDCache openid.DiscoveryCache
5152
openIDNonceStore openid.NonceStore
5253
steamAPIBaseURL string // override for testing; empty uses default
54+
55+
spriteOnce sync.Once
56+
spriteFiles map[string][]byte
57+
spriteInitErr error
5358
}
5459

5560
// HandlerOption configures the Handler
@@ -157,6 +162,11 @@ func NewHandler(
157162
hdlr.GetFont,
158163
hdlr.cacheControl(CacheDuration),
159164
)
165+
g.GET(
166+
"/images/maps/sprites/:name",
167+
hdlr.GetSprite,
168+
hdlr.cacheControl(CacheDuration),
169+
)
160170
g.GET(
161171
"/images/maps/*",
162172
hdlr.GetMapTitle,
@@ -442,6 +452,27 @@ func (h *Handler) GetMarker(c echo.Context) error {
442452
return c.Stream(http.StatusOK, ct, img)
443453
}
444454

455+
func (h *Handler) GetSprite(c echo.Context) error {
456+
h.spriteOnce.Do(func() {
457+
h.spriteFiles, h.spriteInitErr = maptool.GenerateSpriteBytes()
458+
})
459+
if h.spriteInitErr != nil {
460+
return fmt.Errorf("generate sprites: %w", h.spriteInitErr)
461+
}
462+
463+
name := c.Param("name")
464+
data, ok := h.spriteFiles[name]
465+
if !ok {
466+
return echo.ErrNotFound
467+
}
468+
469+
ct := "application/json"
470+
if strings.HasSuffix(name, ".png") {
471+
ct = "image/png"
472+
}
473+
return c.Blob(http.StatusOK, ct, data)
474+
}
475+
445476
func (h *Handler) GetFont(c echo.Context) error {
446477
fontstack := filepath.Base(c.Param("fontstack"))
447478
rangeParam := filepath.Base(c.Param("range"))

internal/server/handler_admin_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
"github.com/stretchr/testify/require"
2020
)
2121

22-
func setupAdminTest(t *testing.T) (Handler, *Operation) {
22+
func setupAdminTest(t *testing.T) (*Handler, *Operation) {
2323
t.Helper()
2424
dir := t.TempDir()
2525
repo, err := NewRepoOperation(filepath.Join(dir, "test.db"))
@@ -34,7 +34,7 @@ func setupAdminTest(t *testing.T) (Handler, *Operation) {
3434
}
3535
require.NoError(t, repo.Store(t.Context(), op))
3636

37-
hdlr := Handler{
37+
hdlr := &Handler{
3838
repoOperation: repo,
3939
setting: Setting{Secret: "test-secret", Data: dir},
4040
jwt: NewJWTManager("test-secret", time.Hour),
@@ -391,7 +391,7 @@ func TestAdminFlow_LoginEditDelete(t *testing.T) {
391391
jwtMgr := NewJWTManager("test-secret", time.Hour)
392392

393393
e := echo.New()
394-
hdlr := Handler{
394+
hdlr := &Handler{
395395
repoOperation: repo,
396396
setting: setting,
397397
jwt: jwtMgr,

internal/server/handler_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,44 @@ func TestGetFont(t *testing.T) {
15181518
assert.Equal(t, http.StatusOK, rec.Code)
15191519
}
15201520

1521+
func TestGetSprite(t *testing.T) {
1522+
hdlr := Handler{}
1523+
1524+
e := echo.New()
1525+
1526+
tests := []struct {
1527+
name string
1528+
wantStatus int
1529+
wantType string
1530+
}{
1531+
{"sprite.json", http.StatusOK, "application/json"},
1532+
{"sprite.png", http.StatusOK, "image/png"},
1533+
{"sprite-dark.json", http.StatusOK, "application/json"},
1534+
{"sprite-dark.png", http.StatusOK, "image/png"},
1535+
{"nonexistent.json", http.StatusNotFound, ""},
1536+
}
1537+
1538+
for _, tt := range tests {
1539+
t.Run(tt.name, func(t *testing.T) {
1540+
req := httptest.NewRequest(http.MethodGet, "/images/maps/sprites/"+tt.name, nil)
1541+
rec := httptest.NewRecorder()
1542+
c := e.NewContext(req, rec)
1543+
c.SetParamNames("name")
1544+
c.SetParamValues(tt.name)
1545+
1546+
err := hdlr.GetSprite(c)
1547+
if tt.wantStatus == http.StatusNotFound {
1548+
assert.Error(t, err)
1549+
} else {
1550+
assert.NoError(t, err)
1551+
assert.Equal(t, tt.wantStatus, rec.Code)
1552+
assert.Contains(t, rec.Header().Get("Content-Type"), tt.wantType)
1553+
assert.Greater(t, rec.Body.Len(), 0)
1554+
}
1555+
})
1556+
}
1557+
}
1558+
15211559
func TestWithMapTool(t *testing.T) {
15221560
jm := maptool.NewJobManager(t.TempDir(), func() *maptool.Pipeline {
15231561
return maptool.NewPipeline(nil)

0 commit comments

Comments
 (0)