Skip to content

Commit 0403588

Browse files
ndbroadbentclaude
andcommitted
Add map style toggle, category icons/colors, and include all activities
- Add tile style toggle (OSM, satellite, terrain) with localStorage persistence - Add --default-style CLI option and MapConfig.defaultStyle property - Add CATEGORY_ICONS (Lucide SVGs) and CATEGORY_COLORS to categories.ts - Include ALL activities in map HTML (not just geocoded) - List shows all activities, map pins only for geocoded ones - Info box shows "X activities (Y with locations)" - Use category-colored placeholders with white icons when no images - Rename MapPoint to MapActivity for clarity - Add hasImages flag to MapData to explicitly control thumbnail rendering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 166fb26 commit 0403588

File tree

14 files changed

+349
-47
lines changed

14 files changed

+349
-47
lines changed

bun.lock

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"lockfileVersion": 1,
3+
"configVersion": 0,
34
"workspaces": {
45
"": {
56
"name": "chat-to-map",
@@ -11,6 +12,7 @@
1112
"i18n-iso-countries": "^7.14.0",
1213
"js-tiktoken": "^1.0.21",
1314
"jszip": "^3.10.1",
15+
"lucide-static": "^0.562.0",
1416
"pdfkit": "^0.17.2",
1517
"sharp": "^0.34.5",
1618
},
@@ -697,6 +699,8 @@
697699

698700
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
699701

702+
"lucide-static": ["lucide-static@0.562.0", "", {}, "sha512-TM2vNVOEsO3+ijmno7n/VmxUo0Shr9OXC/UqZc5n4xEVyXX4E4NVvXoRPAZiSsIsdvlQ7alGOcIC/QGtR+OgUQ=="],
703+
700704
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
701705

702706
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"i18n-iso-countries": "^7.14.0",
8484
"js-tiktoken": "^1.0.21",
8585
"jszip": "^3.10.1",
86+
"lucide-static": "^0.562.0",
8687
"pdfkit": "^0.17.2",
8788
"sharp": "^0.34.5"
8889
},

src/categories.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,36 @@
11
/**
22
* Activity Categories
33
*
4-
* Source of truth for valid categories and their emoji.
4+
* Source of truth for valid categories and their emoji/icons.
55
* Used across classifier, images, exports, and UI.
66
*/
77

8+
import {
9+
Baby,
10+
BookOpen,
11+
Briefcase,
12+
Dumbbell,
13+
Film,
14+
Gamepad2,
15+
Heart,
16+
Home,
17+
Landmark,
18+
MapPin,
19+
Music,
20+
Palette,
21+
PartyPopper,
22+
PawPrint,
23+
Plane,
24+
Puzzle,
25+
ShoppingBag,
26+
Sparkles,
27+
Trees,
28+
Trophy,
29+
Users,
30+
Utensils,
31+
Wine
32+
} from 'lucide-static'
33+
834
export const VALID_CATEGORIES = [
935
'food',
1036
'nightlife',
@@ -59,3 +85,57 @@ export const CATEGORY_EMOJI: Record<ActivityCategory, string> = {
5985
pets: '🐾',
6086
other: '📍'
6187
}
88+
89+
/** Lucide SVG icon for each activity category */
90+
export const CATEGORY_ICONS: Record<ActivityCategory, string> = {
91+
food: Utensils,
92+
nightlife: Wine,
93+
nature: Trees,
94+
arts: Palette,
95+
culture: Landmark,
96+
music: Music,
97+
entertainment: Film,
98+
events: PartyPopper,
99+
sports: Trophy,
100+
fitness: Dumbbell,
101+
wellness: Heart,
102+
shopping: ShoppingBag,
103+
travel: Plane,
104+
experiences: Sparkles,
105+
hobbies: Puzzle,
106+
gaming: Gamepad2,
107+
learning: BookOpen,
108+
home: Home,
109+
work: Briefcase,
110+
social: Users,
111+
family: Baby,
112+
pets: PawPrint,
113+
other: MapPin
114+
}
115+
116+
/** Background color for each activity category (Tailwind CSS 500-600 shades) */
117+
export const CATEGORY_COLORS: Record<ActivityCategory, string> = {
118+
food: '#ef4444', // red-500
119+
nightlife: '#8b5cf6', // violet-500
120+
nature: '#22c55e', // green-500
121+
arts: '#f26b1f', // orange-550 (between 500 and 600)
122+
culture: '#6366f1', // indigo-500
123+
music: '#ec4899', // pink-500
124+
entertainment: '#ca8a04', // yellow-600
125+
events: '#14b8a6', // teal-500
126+
sports: '#3b82f6', // blue-500
127+
fitness: '#f43f5e', // rose-500
128+
wellness: '#d946ef', // fuchsia-500
129+
shopping: '#a855f7', // purple-500
130+
travel: '#0ea5e9', // sky-500
131+
experiences: '#d97706', // amber-600
132+
hobbies: '#65a30d', // lime-600
133+
gaming: '#7c3aed', // violet-600
134+
learning: '#0284c7', // sky-600
135+
home: '#78716c', // stone-500
136+
work: '#64748b', // slate-500
137+
social: '#06b6d4', // cyan-500
138+
family: '#fb7185', // rose-400
139+
pets: '#65a30d', // lime-600
140+
other: '#6b7280' // gray-500
141+
}

src/cli/commands/export.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
filterActivitiesForExport,
1717
VERSION
1818
} from '../../index'
19-
import type { GeocodedActivity } from '../../types'
19+
import type { GeocodedActivity, MapStyle } from '../../types'
2020
import type { CLIArgs } from '../args'
2121
import { buildFilterOptions } from '../filter-options'
2222
import { initCommandContext } from '../helpers'
@@ -127,7 +127,11 @@ export async function cmdExport(args: CLIArgs, logger: Logger): Promise<void> {
127127
}
128128

129129
case 'map': {
130-
const html = exportToMapHTML(filtered, { title: 'Things To Do' })
130+
const defaultStyle = parseMapStyle(args.mapDefaultStyle ?? config?.mapDefaultStyle)
131+
const html = exportToMapHTML(filtered, {
132+
title: 'Things To Do',
133+
...(defaultStyle && { defaultStyle })
134+
})
131135
await writeFile(outputPath, html)
132136
logger.success(`Exported ${filtered.length} activities to ${outputPath}`)
133137
break
@@ -138,3 +142,11 @@ export async function cmdExport(args: CLIArgs, logger: Logger): Promise<void> {
138142
process.exit(1)
139143
}
140144
}
145+
146+
/** Parse map style string to MapStyle type */
147+
function parseMapStyle(style: string | undefined): MapStyle | undefined {
148+
if (style === 'osm' || style === 'satellite' || style === 'terrain') {
149+
return style
150+
}
151+
return undefined
152+
}

src/cli/steps/export.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
filterActivitiesForExport,
1616
VERSION
1717
} from '../../index'
18-
import type { GeocodedActivity, PDFConfig } from '../../types'
18+
import type { GeocodedActivity, MapStyle, PDFConfig } from '../../types'
1919
import type { CLIArgs } from '../args'
2020
import type { Config } from '../config'
2121
import { buildFilterOptions } from '../filter-options'
@@ -112,12 +112,23 @@ export async function stepExport(
112112
// Build PDF config once (only used if PDF format requested)
113113
const pdfConfig = buildPdfConfig(args, config, inputFile, thumbnails)
114114

115+
// Get map default style from args → config
116+
const mapDefaultStyle = parseMapStyle(args.mapDefaultStyle ?? config?.mapDefaultStyle)
117+
115118
for (const format of formats) {
116119
// Build filter options for this specific format (PDF can have different filters than others)
117120
const filter = buildFilterOptions(format, args, config)
118121
const filtered = filterActivitiesForExport(activities, filter)
119122

120-
const path = await exportFormat(format, filtered, outputDir, inputFile, thumbnails, pdfConfig)
123+
const path = await exportFormat(
124+
format,
125+
filtered,
126+
outputDir,
127+
inputFile,
128+
thumbnails,
129+
pdfConfig,
130+
mapDefaultStyle
131+
)
121132
if (path) {
122133
exportedFiles.set(format, path)
123134
}
@@ -126,13 +137,22 @@ export async function stepExport(
126137
return { exportedFiles }
127138
}
128139

140+
/** Parse map style string to MapStyle type */
141+
function parseMapStyle(style: string | undefined): MapStyle | undefined {
142+
if (style === 'osm' || style === 'satellite' || style === 'terrain') {
143+
return style
144+
}
145+
return undefined
146+
}
147+
129148
async function exportFormat(
130149
format: ExportFormat,
131150
activities: readonly GeocodedActivity[],
132151
outputDir: string,
133152
inputFile: string,
134153
thumbnails: Map<string, Buffer> | undefined,
135-
pdfConfig: PDFConfig
154+
pdfConfig: PDFConfig,
155+
mapDefaultStyle: MapStyle | undefined
136156
): Promise<string | null> {
137157
switch (format) {
138158
case 'csv': {
@@ -163,7 +183,11 @@ async function exportFormat(
163183
}
164184
}
165185

166-
const html = exportToMapHTML(activities, { title: 'Things To Do', imagePaths })
186+
const html = exportToMapHTML(activities, {
187+
title: 'Things To Do',
188+
imagePaths,
189+
...(mapDefaultStyle && { defaultStyle: mapDefaultStyle })
190+
})
167191
const mapPath = join(outputDir, 'map.html')
168192
await writeFile(mapPath, html)
169193
return mapPath

src/export/map/app.js.template

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,92 @@
6868
floatingTooltip.style.display = 'none'
6969
}
7070

71+
// Tile layer styles
72+
var tileStyles = {
73+
osm: {
74+
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
75+
attribution: '&copy; OpenStreetMap contributors',
76+
label: 'Street'
77+
},
78+
satellite: {
79+
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
80+
attribution: '&copy; Esri',
81+
label: 'Satellite'
82+
},
83+
terrain: {
84+
url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
85+
attribution: '&copy; OpenTopoMap',
86+
label: 'Terrain'
87+
}
88+
}
89+
90+
// Get saved style from localStorage, or use default from mapData
91+
var STORAGE_KEY = 'chattomap-style'
92+
var savedStyle = null
93+
try {
94+
savedStyle = localStorage.getItem(STORAGE_KEY)
95+
} catch (e) {
96+
// localStorage not available
97+
}
98+
var currentStyle = savedStyle && tileStyles[savedStyle] ? savedStyle : (mapData.defaultStyle || 'osm')
99+
71100
// Initialize map
72101
var map = L.map('map').setView([mapData.center.lat, mapData.center.lng], mapData.zoom)
73102

74-
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
75-
attribution: '&copy; OpenStreetMap contributors'
103+
var currentTileLayer = L.tileLayer(tileStyles[currentStyle].url, {
104+
attribution: tileStyles[currentStyle].attribution
76105
}).addTo(map)
77106

107+
// Style toggle control
108+
function setMapStyle(style) {
109+
if (!tileStyles[style] || style === currentStyle) return
110+
map.removeLayer(currentTileLayer)
111+
currentTileLayer = L.tileLayer(tileStyles[style].url, {
112+
attribution: tileStyles[style].attribution
113+
}).addTo(map)
114+
currentStyle = style
115+
try {
116+
localStorage.setItem(STORAGE_KEY, style)
117+
} catch (e) {
118+
// localStorage not available
119+
}
120+
updateStyleButtons()
121+
}
122+
123+
function updateStyleButtons() {
124+
var buttons = document.querySelectorAll('.style-btn')
125+
buttons.forEach(function (btn) {
126+
var style = btn.getAttribute('data-style')
127+
btn.classList.toggle('active', style === currentStyle)
128+
})
129+
}
130+
131+
// Create style toggle control
132+
var styleControl = document.getElementById('styleControl')
133+
if (styleControl) {
134+
var styleHtml = Object.keys(tileStyles)
135+
.map(function (style) {
136+
var isActive = style === currentStyle ? ' active' : ''
137+
return (
138+
'<button class="style-btn' +
139+
isActive +
140+
'" data-style="' +
141+
style +
142+
'">' +
143+
tileStyles[style].label +
144+
'</button>'
145+
)
146+
})
147+
.join('')
148+
styleControl.innerHTML = styleHtml
149+
styleControl.addEventListener('click', function (e) {
150+
var btn = e.target.closest('.style-btn')
151+
if (btn) {
152+
setMapStyle(btn.getAttribute('data-style'))
153+
}
154+
})
155+
}
156+
78157
var markersLayer = mapData.clusterMarkers
79158
? L.markerClusterGroup({
80159
maxClusterRadius: 50,
@@ -83,8 +162,12 @@
83162
})
84163
: L.layerGroup()
85164

86-
// Add markers
87-
mapData.points.forEach(function (p) {
165+
// Add markers (only for geocoded activities)
166+
var geocodedPoints = mapData.activities.filter(function (p) {
167+
return p.lat !== null && p.lng !== null
168+
})
169+
170+
geocodedPoints.forEach(function (p) {
88171
var messagesEncoded = encodeURIComponent(JSON.stringify(p.messages)).replace(/'/g, '%27')
89172
var senderDisplay = formatSenders(p.messages)
90173
var mentionCount = p.messages.length
@@ -190,7 +273,8 @@
190273

191274
// Render info box
192275
document.getElementById('infoTitle').textContent = mapData.title
193-
document.getElementById('infoCount').textContent = mapData.points.length
276+
document.getElementById('infoCount').textContent = mapData.activities.length
277+
document.getElementById('infoWithLocations').textContent = '(' + geocodedPoints.length + ' with locations)'
194278

195279
// Render legend
196280
var legendHtml = Object.entries(mapData.senderColors)
@@ -214,10 +298,11 @@
214298
document.getElementById('legend').innerHTML = legendHtml
215299

216300
// Activity list
217-
var activities = mapData.points.map(function (p) {
301+
var activities = mapData.activities.map(function (p) {
218302
return {
219303
id: p.activityId,
220304
activity: p.activity,
305+
category: p.category,
221306
sender: p.sender.split(' ')[0],
222307
location: p.location,
223308
date: p.date,
@@ -238,9 +323,23 @@
238323
'&query_place_id=' +
239324
a.placeId
240325
: null
241-
var thumb = a.imagePath
242-
? '<img src="' + a.imagePath + '" class="activity-thumb" alt="" />'
243-
: '<div class="activity-thumb-placeholder"></div>'
326+
var mentionCount = a.messages.length
327+
var badge = mentionCount > 1 ? '<span class="mention-badge">' + mentionCount + '</span>' : ''
328+
329+
var thumbContent
330+
if (mapData.hasImages && a.imagePath) {
331+
thumbContent = '<img src="' + a.imagePath + '" class="activity-thumb" alt="" />'
332+
} else {
333+
var icon = mapData.categoryIcons[a.category] || mapData.categoryIcons.other
334+
var color = mapData.categoryColors[a.category] || mapData.categoryColors.other
335+
thumbContent =
336+
'<div class="activity-thumb-placeholder" style="background-color:' +
337+
color +
338+
';">' +
339+
icon +
340+
'</div>'
341+
}
342+
var thumb = '<div class="activity-thumb-wrapper">' + thumbContent + badge + '</div>'
244343
var links =
245344
(mapsUrl ? '<a href="' + mapsUrl + '" target="_blank">Google Maps</a>' : '') +
246345
(a.url ? '<a href="' + a.url + '" target="_blank">Source</a>' : '')

0 commit comments

Comments
 (0)