diff --git a/backlog/tasks/task-024 - Open-Mapping-Collaborative-Route-Planning-Module.md b/backlog/tasks/task-024 - Open-Mapping-Collaborative-Route-Planning-Module.md new file mode 100644 index 0000000..3702ea1 --- /dev/null +++ b/backlog/tasks/task-024 - Open-Mapping-Collaborative-Route-Planning-Module.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [ ] #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 + + +## Implementation Plan + + +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 + diff --git a/src/automerge/AutomergeToTLStore.ts b/src/automerge/AutomergeToTLStore.ts index d0dd894..6c90a35 100644 --- a/src/automerge/AutomergeToTLStore.ts +++ b/src/automerge/AutomergeToTLStore.ts @@ -3,28 +3,45 @@ import * as Automerge from "@automerge/automerge" // 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 -// Valid indices have an integer part (letter indicating length) followed by digits and optional alphanumeric fraction -// Examples: "a0", "a1", "a1V", "a24sT", "a1V4rr" -// Invalid: "b1" (old format), simple sequential numbers +// The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc. +// Examples: "a0"-"a9", "b10"-"b99", "c100"-"c999", with optional fraction "a1V4rr" +// Invalid: "b1" (b expects 2 digits but has 1), simple sequential numbers function isValidIndexKey(index: string): boolean { if (!index || typeof index !== 'string' || index.length === 0) { return false } - // tldraw uses fractional indexing where: - // - First character is a lowercase letter indicating integer part length (a=1, b=2, c=3, etc.) - // - Followed by alphanumeric characters for the value and optional jitter - // 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 + // Must start with a letter + if (!/^[a-zA-Z]/.test(index)) { + return false } - // Also allow uppercase prefix for negative/very high indices - if (/^[A-Z][a-zA-Z0-9]+$/.test(index)) { + const prefix = index[0] + 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 } diff --git a/src/automerge/MinimalSanitization.ts b/src/automerge/MinimalSanitization.ts index 1613e85..904ba5d 100644 --- a/src/automerge/MinimalSanitization.ts +++ b/src/automerge/MinimalSanitization.ts @@ -21,19 +21,36 @@ function minimalSanitizeRecord(record: any): any { if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1 if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {} // NOTE: Index assignment is handled by assignSequentialIndices() during format conversion - // Here we only ensure index exists with a valid format, not strictly validate - // This preserves layer order that was established during conversion - // tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc. - // - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.) - // - Uppercase (A-Z) for negative/special indices + // Here we validate using tldraw's fractional indexing rules + // The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc. + // Examples: "a0"-"a9", "b10"-"b99", "c100"-"c999", with optional fraction "a1V4rr" + // Invalid: "b1" (b expects 2 digits but has 1) 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' + } 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' diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 4fc7ddc..cff42b6 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -68,18 +68,50 @@ import "@/css/style.css" import "@/css/obsidian-browser.css" // Helper to validate and fix tldraw IndexKey format -// tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc. -// - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.) -// - Uppercase (A-Z) for negative/special indices +// tldraw uses fractional indexing where the first letter encodes integer part length: +// - 'a' = 1-digit integer (a0-a9), 'b' = 2-digit (b10-b99), 'c' = 3-digit (c100-c999), etc. +// - 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 { if (!index || typeof index !== 'string' || index.length === 0) { 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)) { return index as IndexKey } - // Fallback for invalid formats + return 'a1' as IndexKey }