Last active
March 8, 2026 17:12
-
-
Save troutcolor/409232f0772992c3acbef5afd07add76 to your computer and use it in GitHub Desktop.
makes 9x9 montages of photos the middle image is a map where the photos are taken and a gpx track. photos need to be geotagged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| // gpx2geojson.js — Convert a GPX track to a URL-encoded GeoJSON string (≤1024 chars) | |
| // | |
| // Usage: | |
| // node gpx2geojson.js [options] <file.gpx> | |
| // | |
| // Options: | |
| // --max-len <n> Max URL-encoded output length (default: 1024) | |
| // --tolerance <n> Starting RDP epsilon in degrees (default: 0.0001) | |
| // --linecolor <hex> Line colour (default: #0099ff) | |
| // --lineopacity <n> Line opacity 0–1 (default: 0.9) | |
| // --linewidth <n> Line width px (default: 3) | |
| // --linestyle <s> solid|dotted|dashed|longdash (default: solid) | |
| // --fillcolor <hex> Fill colour (default: #0099ff) | |
| // --fillopacity <n> Fill opacity 0–1 (default: 0.1) | |
| // --precision <n> Coordinate decimal places (default: 5) | |
| // --quiet Suppress info/stats output (stdout = URL string only) | |
| // --json Output raw (non-encoded) GeoJSON instead of URL-encoded | |
| // --help Show this help | |
| // | |
| // Exit codes: 0 = success, 1 = error | |
| // | |
| // No external dependencies — uses only Node.js built-ins. | |
| 'use strict'; | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| // ── Argument parsing ─────────────────────────────────────────────────────── | |
| function parseArgs(argv) { | |
| const args = argv.slice(2); | |
| const opts = { | |
| maxLen: 1024, | |
| tolerance: 0.0001, | |
| linecolor: '#0099ff', | |
| lineopacity: 0.9, | |
| linewidth: 3, | |
| linestyle: 'solid', | |
| fillcolor: '#0099ff', | |
| fillopacity: 0.1, | |
| precision: 5, | |
| quiet: false, | |
| json: false, | |
| file: null, | |
| }; | |
| for (let i = 0; i < args.length; i++) { | |
| const a = args[i]; | |
| switch (a) { | |
| case '--help': printHelp(); process.exit(0); break; | |
| case '--quiet': opts.quiet = true; break; | |
| case '--json': opts.json = true; break; | |
| case '--max-len': opts.maxLen = parseInt(args[++i]); break; | |
| case '--tolerance': opts.tolerance = parseFloat(args[++i]); break; | |
| case '--linecolor': opts.linecolor = args[++i]; break; | |
| case '--lineopacity': opts.lineopacity = parseFloat(args[++i]); break; | |
| case '--linewidth': opts.linewidth = parseInt(args[++i]); break; | |
| case '--linestyle': opts.linestyle = args[++i]; break; | |
| case '--fillcolor': opts.fillcolor = args[++i]; break; | |
| case '--fillopacity': opts.fillopacity = parseFloat(args[++i]); break; | |
| case '--precision': opts.precision = parseInt(args[++i]); break; | |
| default: | |
| if (!a.startsWith('--')) opts.file = a; | |
| else { die(`Unknown option: ${a}`); } | |
| } | |
| } | |
| if (!opts.file) die('No GPX file specified. Use --help for usage.'); | |
| if (!['solid','dotted','dashed','longdash'].includes(opts.linestyle)) | |
| die('--linestyle must be one of: solid, dotted, dashed, longdash'); | |
| return opts; | |
| } | |
| function printHelp() { | |
| console.log(` | |
| gpx2geojson — GPX track → URL-encoded GeoJSON (≤N chars, default 1024) | |
| Usage: | |
| node gpx2geojson.js [options] <file.gpx> | |
| Options: | |
| --max-len <n> Max encoded length (default: 1024) | |
| --tolerance <n> Starting RDP epsilon ° (default: 0.0001) | |
| --linecolor <hex> Line colour (default: #0099ff) | |
| --lineopacity <n> Line opacity 0–1 (default: 0.9) | |
| --linewidth <n> Line width px (default: 3) | |
| --linestyle <s> solid|dotted|dashed|longdash (default: solid) | |
| --fillcolor <hex> Fill colour (default: #0099ff) | |
| --fillopacity <n> Fill opacity 0–1 (default: 0.1) | |
| --precision <n> Coordinate decimal places (default: 5) | |
| --quiet Suppress stderr info/stats | |
| --json Output raw GeoJSON (not URL-encoded) | |
| --help Show this help | |
| Output: | |
| URL-encoded GeoJSON string is written to stdout. | |
| Stats/info are written to stderr (suppressed with --quiet). | |
| Examples: | |
| node gpx2geojson.js track.gpx | |
| node gpx2geojson.js --linecolor '#ff3366' --linewidth 4 track.gpx | |
| node gpx2geojson.js --max-len 2048 --quiet track.gpx | |
| node gpx2geojson.js --json track.gpx | python3 -m json.tool | |
| `); | |
| } | |
| function die(msg) { | |
| process.stderr.write(`ERROR: ${msg}\n`); | |
| process.exit(1); | |
| } | |
| // ── GPX parser (no DOM, pure regex/string) ───────────────────────────────── | |
| function parseGPX(text) { | |
| const coords = []; | |
| // Match <trkpt lat="..." lon="..."> or <wpt lat="..." lon="..."> | |
| const re = /<(?:trkpt|wpt)\s[^>]*lat="([^"]+)"[^>]*lon="([^"]+)"/g; | |
| const re2 = /<(?:trkpt|wpt)\s[^>]*lon="([^"]+)"[^>]*lat="([^"]+)"/g; | |
| let m; | |
| while ((m = re.exec(text)) !== null) { | |
| const lat = parseFloat(m[1]), lon = parseFloat(m[2]); | |
| if (!isNaN(lat) && !isNaN(lon)) coords.push([lon, lat]); | |
| } | |
| if (coords.length === 0) { | |
| while ((m = re2.exec(text)) !== null) { | |
| const lon = parseFloat(m[1]), lat = parseFloat(m[2]); | |
| if (!isNaN(lat) && !isNaN(lon)) coords.push([lon, lat]); | |
| } | |
| } | |
| return coords; | |
| } | |
| // ── Ramer–Douglas–Peucker ────────────────────────────────────────────────── | |
| function perpDist(pt, a, b) { | |
| const dx = b[0]-a[0], dy = b[1]-a[1]; | |
| if (dx === 0 && dy === 0) return Math.hypot(pt[0]-a[0], pt[1]-a[1]); | |
| const t = ((pt[0]-a[0])*dx + (pt[1]-a[1])*dy) / (dx*dx + dy*dy); | |
| return Math.hypot(pt[0] - (a[0]+t*dx), pt[1] - (a[1]+t*dy)); | |
| } | |
| function rdp(points, eps) { | |
| if (points.length <= 2) return points.slice(); | |
| let maxD = 0, maxI = 0; | |
| for (let i = 1; i < points.length - 1; i++) { | |
| const d = perpDist(points[i], points[0], points[points.length-1]); | |
| if (d > maxD) { maxD = d; maxI = i; } | |
| } | |
| if (maxD > eps) { | |
| const L = rdp(points.slice(0, maxI+1), eps); | |
| const R = rdp(points.slice(maxI), eps); | |
| return [...L.slice(0,-1), ...R]; | |
| } | |
| return [points[0], points[points.length-1]]; | |
| } | |
| // ── GeoJSON builder ──────────────────────────────────────────────────────── | |
| function buildGeoJSON(coords, props, precision) { | |
| const p = precision; | |
| const coordStr = coords.map(c => `[${c[0].toFixed(p)},${c[1].toFixed(p)}]`).join(','); | |
| return `{"type":"Feature","properties":${JSON.stringify(props)},"geometry":{"type":"LineString","coordinates":[${coordStr}]}}`; | |
| } | |
| // ── Auto-fit: binary-search epsilon until encoded length ≤ maxLen ────────── | |
| function autoFit(coords, props, maxLen, startEps, precision) { | |
| const measure = pts => encodeURIComponent(buildGeoJSON(pts, props, precision)).length; | |
| // Always valid baseline: just endpoints | |
| const baseline = [coords[0], coords[coords.length-1]]; | |
| if (measure(baseline) > maxLen) return null; | |
| // Try the requested tolerance first (fast path) | |
| const quick = rdp(coords, startEps); | |
| if (measure(quick) <= maxLen) return { simplified: quick, epsilon: startEps }; | |
| // Binary search on epsilon | |
| let lo = startEps, hi = 180.0, best = null; | |
| for (let i = 0; i < 60; i++) { | |
| const mid = (lo + hi) / 2; | |
| const simp = rdp(coords, mid); | |
| if (measure(simp) <= maxLen) { best = { simplified: simp, epsilon: mid }; hi = mid; } | |
| else lo = mid; | |
| if (hi - lo < 1e-9) break; | |
| } | |
| if (best) return best; | |
| // Last resort: uniform stride | |
| for (let stride = 2; stride <= coords.length; stride++) { | |
| const thinned = coords.filter((_, i) => i % stride === 0 || i === coords.length-1); | |
| if (thinned.length < 2) break; | |
| if (measure(thinned) <= maxLen) return { simplified: thinned, epsilon: null }; | |
| } | |
| return null; | |
| } | |
| // ── Main ─────────────────────────────────────────────────────────────────── | |
| (function main() { | |
| const opts = parseArgs(process.argv); | |
| // Read file | |
| let gpxText; | |
| try { | |
| gpxText = fs.readFileSync(opts.file, 'utf8'); | |
| } catch (e) { | |
| die(`Cannot read file "${opts.file}": ${e.message}`); | |
| } | |
| // Parse | |
| const coords = parseGPX(gpxText); | |
| if (coords.length < 2) die('No track points found in GPX file (need at least 2).'); | |
| const info = s => { if (!opts.quiet) process.stderr.write(s + '\n'); }; | |
| info(`Input: ${coords.length.toLocaleString()} track points (${path.basename(opts.file)})`); | |
| const props = { | |
| linecolor: opts.linecolor, | |
| lineopacity: opts.lineopacity, | |
| linewidth: opts.linewidth, | |
| linestyle: opts.linestyle, | |
| fillcolor: opts.fillcolor, | |
| fillopacity: opts.fillopacity, | |
| }; | |
| if (opts.json) { | |
| // Raw GeoJSON mode — simplify to fit, but output decoded JSON | |
| const result = autoFit(coords, props, opts.maxLen, opts.tolerance, opts.precision); | |
| if (!result) die('Cannot produce output within the character limit.'); | |
| const gj = buildGeoJSON(result.simplified, props, opts.precision); | |
| info(`Simplified: ${result.simplified.length} points (${((1 - result.simplified.length/coords.length)*100).toFixed(1)}% reduction)`); | |
| process.stdout.write(gj + '\n'); | |
| return; | |
| } | |
| // URL-encoded mode | |
| const result = autoFit(coords, props, opts.maxLen, opts.tolerance, opts.precision); | |
| if (!result) die('Cannot fit track in the specified character limit.'); | |
| const gj = buildGeoJSON(result.simplified, props, opts.precision); | |
| const encoded = encodeURIComponent(gj); | |
| info(`Simplified: ${result.simplified.length} points (${((1 - result.simplified.length/coords.length)*100).toFixed(1)}% reduction)`); | |
| info(`Epsilon: ${result.epsilon !== null ? result.epsilon.toFixed(8) + '°' : 'uniform stride'}`); | |
| info(`Output: ${encoded.length} / ${opts.maxLen} chars`); | |
| process.stdout.write(encoded + '\n'); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- I use a hard coded folder to hold the photo. I export from photos app. The folder needs 8 photos with geotags & a gpx track | |
| --needs imagemagick/convert | |
| --update 8 Mar 2026 I got claude to make me a node script to shrink a gpx file to 1024 char encodes geoJSON. This allows me to darw a map. You need to select the gpx track. | |
| -- so now needs mnode | |
| --added this: exiftool '-FileName<DateTimeOriginal' -d "%Y-%m-%d-%H-%M-%S%%-c.%%e" *.jpeg | |
| --so need exiftool | |
| --changed from mapquest to geoapify NEED AN API key https://www.geoapify.com | |
| set pathtogpx2geojson to "/Path/To/gpx2geojson.js" | |
| set pathtonode to "/Path/To/Node" | |
| set APIKEY to "ThisIsWhereTheAPIkeyGoes" | |
| set gpxFile to POSIX path of (choose file with prompt "Pick a GPX file" of type {"gpx"}) | |
| set encodedGeoJSON to do shell script pathtonode & " " & pathtogpx2geojson & " --quiet --linecolor '#ff3366' " & quoted form of gpxFile | |
| set eightcolours to {"ff0000", "0000ff", "00ff00", "ffff00", "ffa500", "800080", "ffffff", "000000"} | |
| set thefolder to "/Users/john/Desktop/flickr-temp/grid/" | |
| set mytempFolder to do shell script "mktemp -d " | |
| do shell script "cd " & thefolder & "; /usr/local/bin/exiftool '-FileName<DateTimeOriginal' -d \"%Y-%m-%d-%H-%M-%S%%-c.%%e\" *.jpeg" | |
| --open thefolder to see what is going on | |
| --do shell script "open " & mytempFolder | |
| --get list of files in folder | |
| tell application "System Events" | |
| set FileList to POSIX path of disk items of folder thefolder whose name extension is "jpeg" or name extension is "jpg" | |
| end tell | |
| --just sorting by name as export from photos with filename give chronlogical | |
| --eventually should sort by date taken... The exiftool line above is a kludge | |
| set FileList to simple_sort(the FileList) | |
| set FileList to items 1 through 8 of FileList | |
| --use the original files to get location | |
| --cause if you copy with cp they seem to lose geo | |
| set loclist to "" | |
| set c to 1 | |
| repeat with file_ in FileList | |
| repeat 1 times | |
| set imgkind to kind of (info for file_) | |
| if (imgkind does not contain "Image") then | |
| exit repeat -- 1 time | |
| end if | |
| set file_ to quoted form of POSIX path of (file_ as string) | |
| set latTotal to 0 | |
| set longTotal to 0 | |
| --set thelat to do shell script "mdls -name kMDItemLatitude " & file_ | |
| --set thelon to do shell script "mdls -name kMDItemLongitude " & file_ | |
| --exiftool -GPSLatitude -n | |
| set thelat to do shell script "/usr/local/bin/exiftool -GPSLatitude -n " & file_ & "| cut -d ':' -f 2 | tr -d ' '" | |
| set thelon to do shell script "/usr/local/bin/exiftool -GPSLongitude -n " & file_ & "| cut -d ':' -f 2 | tr -d ' '" | |
| set latTotal to thelat + latTotal | |
| set longTotal to thelon + longTotal | |
| set olddel to AppleScript's text item delimiters | |
| set AppleScript's text item delimiters to "= " | |
| --set loclist to loclist & text item 2 of thelat & "," & text item 2 of thelon & "|circle-md-" & item c of eightcolours & "||" | |
| --set loclist to loclist & thelat & "," & thelon & "|circle-md-" & item c of eightcolours & "||" | |
| set loclist to loclist & "lonlat:" & thelon & "," & thelat & ";type:circle;color:%23" & item c of eightcolours & ";size:small;icontype:material;whitecircle:no|" | |
| --lonlat:-4.687856955889288,56.074354156448436;type:circle;color:%2346a7b5;size:small;icontype:material;whitecircle:no| | |
| set AppleScript's text item delimiters to olddel | |
| set c to c + 1 | |
| end repeat | |
| --loclist now got list for mapquest api location & icon | |
| end repeat | |
| set loclist to text 1 thru -2 of loclist | |
| --display dialog loclist | |
| set latAverage to latTotal / 8 | |
| set longAverage to longTotal / 8 | |
| --¢er=lonlat:-122.304378,47.526022 | |
| --get the map for geoapify | |
| set theurl to "https://maps.geoapify.com/v1/staticmap?style=osm-carto&width=600&height=600&zoom=11.8923&marker=" & loclist & "&apiKey=" & APIKEY & "&geojson=" & encodedGeoJSON | |
| --¢er=lonlat:" & latAverage & "," & longAverage | |
| do shell script "curl '" & theurl & "' > " & mytempFolder & "/map.jpg" | |
| --copy all the images into the temp folder | |
| --this seems to lose geo exif tags (probably other stuff too) | |
| do shell script "cp -a " & thefolder & "*.jpeg " & mytempFolder | |
| --Change file list to new files | |
| --and sort | |
| tell application "System Events" | |
| set FileList to POSIX path of disk items of folder mytempFolder whose name extension is "jpeg" | |
| end tell | |
| set FileList to simple_sort(the FileList) | |
| set FileList to items 1 through 8 of FileList | |
| --this loop with make the images square, resize them and add circles | |
| --I expect this could be done in one fell swoop with imagemagick/convert | |
| repeat with n from 1 to 8 | |
| set file_ to item n of FileList | |
| squareImage(file_) | |
| do shell script "sips --resampleWidth 600 " & file_ | |
| do shell script "/opt/homebrew/bin/convert " & file_ & " -draw 'fill #" & item n of eightcolours & " circle 580,580,596,596' " & file_ | |
| end repeat | |
| --make a list of files for the montage, in chronological order with map in the middle | |
| set montageList to "" | |
| repeat with n from 1 to 4 | |
| set montageList to montageList & "'" & item n of FileList & "' " | |
| end repeat | |
| set montageList to montageList & "'" & mytempFolder & "/map.jpg' " | |
| repeat with n from 5 to 8 | |
| set montageList to montageList & "'" & item n of FileList & "' " | |
| end repeat | |
| --make a montage | |
| --use try cause imagemagic is complainging about fonts | |
| try | |
| set mon to do shell script "set -e;cd " & mytempFolder & "; /opt/homebrew/bin/montage -mode concatenate -tile 3x " & montageList & " out.jpg > /dev/null 2>&1 " | |
| end try | |
| --need to wait until imagemagick does its stuff | |
| tell application "Finder" | |
| repeat | |
| if exists mytempFolder & "/out.jpg" as POSIX file then | |
| exit repeat | |
| else | |
| delay 5 | |
| end if | |
| end repeat | |
| end tell | |
| set resultFile to POSIX path of (choose file name with prompt "Save As File" default name "map-square.jpg" default location path to desktop) as text | |
| do shell script "mv " & mytempFolder & "/out.jpg " & quoted form of resultFile | |
| ---display dialog montageList | |
| on simple_sort(my_list) | |
| set the index_list to {} | |
| set the sorted_list to {} | |
| repeat (the number of items in my_list) times | |
| set the low_item to "" | |
| repeat with i from 1 to (number of items in my_list) | |
| if i is not in the index_list then | |
| set this_item to item i of my_list as text | |
| if the low_item is "" then | |
| set the low_item to this_item | |
| set the low_item_index to i | |
| else if this_item comes before the low_item then | |
| set the low_item to this_item | |
| set the low_item_index to i | |
| end if | |
| end if | |
| end repeat | |
| set the end of sorted_list to the low_item | |
| set the end of the index_list to the low_item_index | |
| end repeat | |
| return the sorted_list | |
| end simple_sort | |
| on squareImage(this_file) | |
| try | |
| tell application "Image Events" | |
| -- start the Image Events application | |
| launch | |
| -- open the image file | |
| set this_image to open this_file | |
| -- get dimensions of the image | |
| copy dimensions of this_image to {W, H} | |
| -- determine scale length | |
| if W is less than H then | |
| set the side_length to W | |
| else | |
| set the side_length to H | |
| end if | |
| -- perform action | |
| crop this_image to dimensions {side_length, side_length} | |
| -- save the changes | |
| save this_image with icon | |
| -- purge the open image data | |
| close this_image | |
| end tell | |
| on error error_message | |
| display dialog error_message | |
| end try | |
| return {W, H} | |
| end squareImage | |
| on change_case_of(this_text, this_case) | |
| if this_case is "lower" then | |
| set the comparison_string to " " | |
| set the source_string to "_" | |
| else | |
| set the comparison_string to "abcdefghijklmnopqrstuvwxyz" | |
| set the source_string to "ABCDEFGHIJKLMNOPQRSTUVWXYZ" | |
| end if | |
| set the new_text to "" | |
| repeat with thisChar in this_text | |
| set x to the offset of thisChar in the comparison_string | |
| if x is not 0 then | |
| set the new_text to (the new_text & character x of the source_string) as string | |
| else | |
| set the new_text to (the new_text & thisChar) as string | |
| end if | |
| end repeat | |
| return the new_text | |
| end change_case_of |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment