Merge branch 'main' into feature/open-mapping
This commit is contained in:
commit
8dac699acf
|
|
@ -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 -->
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue