Skip to content

Instantly share code, notes, and snippets.

@troutcolor
Last active March 8, 2026 17:12
Show Gist options
  • Select an option

  • Save troutcolor/409232f0772992c3acbef5afd07add76 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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');
})();
-- 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
--&center=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
--&center=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