Merge branch 'main' into feature/open-mapping

This commit is contained in:
Jeff Emmett 2025-12-04 06:50:37 -08:00
commit 8dac699acf
4 changed files with 160 additions and 31 deletions

View File

@ -0,0 +1,63 @@
---
id: task-024
title: 'Open Mapping: Collaborative Route Planning Module'
status: To Do
assignee: []
created_date: '2025-12-04 14:30'
labels:
- feature
- mapping
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement an open-source mapping and routing layer for the canvas that provides advanced route planning capabilities beyond Google Maps. Built on OpenStreetMap, OSRM/Valhalla, and MapLibre GL JS.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 MapLibre GL JS integrated with tldraw canvas
- [ ] #2 OSRM routing backend deployed to Netcup
- [ ] #3 Waypoint placement and route calculation working
- [ ] #4 Multi-route comparison UI implemented
- [ ] #5 Y.js collaboration for shared route editing
- [ ] #6 Layer management panel with basemap switching
- [ ] #7 Offline tile caching via Service Worker
- [ ] #8 Budget tracking per waypoint/route
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Phase 1 - Foundation:
- Integrate MapLibre GL JS with tldraw
- Deploy OSRM to /opt/apps/open-mapping/
- Basic waypoint and route UI
Phase 2 - Multi-Route:
- Alternative routes visualization
- Route comparison panel
- Elevation profiles
Phase 3 - Collaboration:
- Y.js integration
- Real-time cursor presence
- Share links
Phase 4 - Layers:
- Layer panel UI
- Multiple basemaps
- Custom overlays
Phase 5 - Calendar/Budget:
- Time windows on waypoints
- Cost estimation
- iCal export
Phase 6 - Optimization:
- VROOM TSP/VRP
- Offline PWA
<!-- SECTION:PLAN:END -->

View File

@ -3,28 +3,45 @@ import * as Automerge from "@automerge/automerge"
// Helper function to validate if a string is a valid tldraw IndexKey // Helper function to validate if a string is a valid tldraw IndexKey
// tldraw uses fractional indexing based on https://observablehq.com/@dgreensp/implementing-fractional-indexing // tldraw uses fractional indexing based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
// Valid indices have an integer part (letter indicating length) followed by digits and optional alphanumeric fraction // The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc.
// Examples: "a0", "a1", "a1V", "a24sT", "a1V4rr" // Examples: "a0"-"a9", "b10"-"b99", "c100"-"c999", with optional fraction "a1V4rr"
// Invalid: "b1" (old format), simple sequential numbers // Invalid: "b1" (b expects 2 digits but has 1), simple sequential numbers
function isValidIndexKey(index: string): boolean { function isValidIndexKey(index: string): boolean {
if (!index || typeof index !== 'string' || index.length === 0) { if (!index || typeof index !== 'string' || index.length === 0) {
return false return false
} }
// tldraw uses fractional indexing where: // Must start with a letter
// - First character is a lowercase letter indicating integer part length (a=1, b=2, c=3, etc.) if (!/^[a-zA-Z]/.test(index)) {
// - Followed by alphanumeric characters for the value and optional jitter return false
// Examples: "a0", "a1", "b10", "b99", "c100", "a1V4rr", "b10Lz"
//
// Also uppercase letters for negative indices (Z=1, Y=2, etc.)
// Valid fractional index: lowercase letter followed by alphanumeric characters
if (/^[a-z][a-zA-Z0-9]+$/.test(index)) {
return true
} }
// Also allow uppercase prefix for negative/very high indices const prefix = index[0]
if (/^[A-Z][a-zA-Z0-9]+$/.test(index)) { const rest = index.slice(1)
// For lowercase prefixes, validate digit count matches the prefix
if (prefix >= 'a' && prefix <= 'z') {
// Calculate expected minimum digit count: a=1, b=2, c=3, etc.
const expectedDigits = prefix.charCodeAt(0) - 'a'.charCodeAt(0) + 1
// Extract the integer part (leading digits)
const integerMatch = rest.match(/^(\d+)/)
if (!integerMatch) {
// No digits at all - invalid
return false
}
const integerPart = integerMatch[1]
// Check if integer part has correct number of digits for the prefix
if (integerPart.length < expectedDigits) {
// Invalid: "b1" has b (expects 2 digits) but only has 1 digit
return false
}
}
// Check overall format: letter followed by alphanumeric
if (/^[a-zA-Z][a-zA-Z0-9]+$/.test(index)) {
return true return true
} }

View File

@ -21,19 +21,36 @@ function minimalSanitizeRecord(record: any): any {
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1 if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {} if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {}
// NOTE: Index assignment is handled by assignSequentialIndices() during format conversion // NOTE: Index assignment is handled by assignSequentialIndices() during format conversion
// Here we only ensure index exists with a valid format, not strictly validate // Here we validate using tldraw's fractional indexing rules
// This preserves layer order that was established during conversion // The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc.
// tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc. // Examples: "a0"-"a9", "b10"-"b99", "c100"-"c999", with optional fraction "a1V4rr"
// - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.) // Invalid: "b1" (b expects 2 digits but has 1)
// - Uppercase (A-Z) for negative/special indices
if (!sanitized.index || typeof sanitized.index !== 'string' || sanitized.index.length === 0) { if (!sanitized.index || typeof sanitized.index !== 'string' || sanitized.index.length === 0) {
// Only assign default if truly missing
sanitized.index = 'a1'
} else if (!/^[a-zA-Z][a-zA-Z0-9]+$/.test(sanitized.index)) {
// Accept any letter followed by alphanumeric characters
// Only reset clearly invalid formats (e.g., numbers, empty, single char)
console.warn(`⚠️ MinimalSanitization: Invalid index format "${sanitized.index}" for shape ${sanitized.id}`)
sanitized.index = 'a1' sanitized.index = 'a1'
} else {
// Validate fractional indexing format
let isValid = false
const prefix = sanitized.index[0]
const rest = sanitized.index.slice(1)
if (/^[a-zA-Z]/.test(sanitized.index) && /^[a-zA-Z][a-zA-Z0-9]+$/.test(sanitized.index)) {
if (prefix >= 'a' && prefix <= 'z') {
// Calculate expected minimum digit count: a=1, b=2, c=3, etc.
const expectedDigits = prefix.charCodeAt(0) - 'a'.charCodeAt(0) + 1
const integerMatch = rest.match(/^(\d+)/)
if (integerMatch && integerMatch[1].length >= expectedDigits) {
isValid = true
}
} else if (prefix >= 'A' && prefix <= 'Z') {
// Uppercase for negative/special indices - allow
isValid = true
}
}
if (!isValid) {
console.warn(`⚠️ MinimalSanitization: Invalid index format "${sanitized.index}" for shape ${sanitized.id}`)
sanitized.index = 'a1'
}
} }
if (!sanitized.parentId) sanitized.parentId = 'page:page' if (!sanitized.parentId) sanitized.parentId = 'page:page'

View File

@ -68,18 +68,50 @@ import "@/css/style.css"
import "@/css/obsidian-browser.css" import "@/css/obsidian-browser.css"
// Helper to validate and fix tldraw IndexKey format // Helper to validate and fix tldraw IndexKey format
// tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc. // tldraw uses fractional indexing where the first letter encodes integer part length:
// - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.) // - 'a' = 1-digit integer (a0-a9), 'b' = 2-digit (b10-b99), 'c' = 3-digit (c100-c999), etc.
// - Uppercase (A-Z) for negative/special indices // - Optional fractional part can follow (a1V, a1V4rr, etc.)
// Common invalid formats from old data: "b1" (b expects 2 digits but has 1)
function sanitizeIndex(index: any): IndexKey { function sanitizeIndex(index: any): IndexKey {
if (!index || typeof index !== 'string' || index.length === 0) { if (!index || typeof index !== 'string' || index.length === 0) {
return 'a1' as IndexKey return 'a1' as IndexKey
} }
// Valid: letter followed by alphanumeric characters
// Must start with a letter
if (!/^[a-zA-Z]/.test(index)) {
return 'a1' as IndexKey
}
// Check fractional indexing rules for lowercase prefixes
const prefix = index[0]
const rest = index.slice(1)
if (prefix >= 'a' && prefix <= 'z') {
// Calculate expected minimum digit count: a=1, b=2, c=3, etc.
const expectedDigits = prefix.charCodeAt(0) - 'a'.charCodeAt(0) + 1
// Extract the integer part (leading digits)
const integerMatch = rest.match(/^(\d+)/)
if (!integerMatch) {
// No digits at all - invalid
return 'a1' as IndexKey
}
const integerPart = integerMatch[1]
// Check if integer part has correct number of digits for the prefix
if (integerPart.length < expectedDigits) {
// Invalid: "b1" has b (expects 2 digits) but only has 1 digit
// Convert to safe format
return 'a1' as IndexKey
}
}
// Check overall format: letter followed by alphanumeric
if (/^[a-zA-Z][a-zA-Z0-9]+$/.test(index)) { if (/^[a-zA-Z][a-zA-Z0-9]+$/.test(index)) {
return index as IndexKey return index as IndexKey
} }
// Fallback for invalid formats
return 'a1' as IndexKey return 'a1' as IndexKey
} }