feat: implement binary Automerge CRDT sync and open-mapping module
Binary Automerge Sync: - CloudflareAdapter: binary sync messages with documentId tracking - Message buffering for early server messages before documentId set - Worker sends initial sync on WebSocket connect - Removed JSON HTTP POST sync in favor of native Automerge protocol - Multi-client binary sync verified working Worker CRDT Infrastructure: - automerge-init.ts: WASM initialization for Cloudflare Workers - automerge-sync-manager.ts: sync state management per peer - automerge-r2-storage.ts: binary document persistence to R2 - AutomergeDurableObject: integrated CRDT sync handling Open Mapping Module: - Collaborative map component with real-time sync - MapShapeUtil for tldraw canvas integration - Presence layer with location sharing - Privacy system with ZK-GPS protocol concepts - Mycelium network for organic route visualization - Conic sections for map projection optimization - Discovery system (spores, hunts, collectibles, anchors) - Geographic transformation utilities UI Updates: - ConnectionStatusIndicator for offline/sync status - Map tool in toolbar - Context menu updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
17250fe056
commit
2dd8f90d5b
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
id: task-005
|
||||
title: Automerge CRDT Sync
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-12-03'
|
||||
labels: [feature, sync, collaboration]
|
||||
priority: high
|
||||
branch: Automerge
|
||||
---
|
||||
|
||||
## Description
|
||||
Implement Automerge CRDT-based synchronization for real-time collaborative canvas editing.
|
||||
|
||||
## Branch Info
|
||||
- **Branch**: `Automerge`
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Integrate Automerge library
|
||||
- [ ] Enable real-time sync between clients
|
||||
- [ ] Handle conflict resolution automatically
|
||||
- [ ] Persist state across sessions
|
||||
|
|
@ -44,6 +44,7 @@
|
|||
"jotai": "^2.6.0",
|
||||
"jspdf": "^2.5.2",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"maplibre-gl": "^5.14.0",
|
||||
"marked": "^15.0.4",
|
||||
"one-webcrypto": "^1.0.3",
|
||||
"openai": "^4.79.3",
|
||||
|
|
@ -3290,6 +3291,109 @@
|
|||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/geojson-rewind": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
|
||||
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"get-stream": "^6.0.1",
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"geojson-rewind": "geojson-rewind"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
|
||||
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.3.1.tgz",
|
||||
"integrity": "sha512-TUM5JD40H2mgtVXl5IwWz03BuQabw8oZQLJTmPpJA0YTYF+B+oZppy5lNMO6bMvHzB+/5mxqW9VLG3wFdeqtOw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"rw": "^1.3.3",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.2.tgz",
|
||||
"integrity": "sha512-SQKdJ909VGROkA6ovJgtHNs9YXV4YXUPS+VaZ50I2Mt951SLlUm2Cv34x5Xwc1HiFlsd3h2Yrs5cn7xzqBmENw==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.1.0.tgz",
|
||||
"integrity": "sha512-9LjFAoWtxdGRns8RK9vG3Fcw/fb3eHMxvAn2jffwn3jnVO1k49VOv6+FEza70rK7WzF8GnBiKa0K39RyfevKUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@types/geojson-vt": "3.2.5",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"pbf": "^4.0.1",
|
||||
"supercluster": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
|
|
@ -6731,6 +6835,21 @@
|
|||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson-vt": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
|
||||
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
|
|
@ -6938,6 +7057,15 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supercluster": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tern": {
|
||||
"version": "0.23.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
|
||||
|
|
@ -9386,6 +9514,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
|
|
@ -10130,6 +10264,12 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geojson-vt": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
|
||||
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
|
|
@ -10186,6 +10326,18 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||
|
|
@ -10205,6 +10357,12 @@
|
|||
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob-to-regexp": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
|
||||
|
|
@ -11714,6 +11872,12 @@
|
|||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
|
|
@ -11786,6 +11950,12 @@
|
|||
"integrity": "sha512-La5CP41Ycv52+E4g7w1sRV8XXk7Sp8a/TwWQAYQKn6RsQz1FD4Z/rDRRmqV3wJznS1MDF3YxK7BCudX1J8FxLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keystore-idb": {
|
||||
"version": "0.15.5",
|
||||
"resolved": "https://registry.npmjs.org/keystore-idb/-/keystore-idb-0.15.5.tgz",
|
||||
|
|
@ -12070,6 +12240,44 @@
|
|||
"integrity": "sha512-NWtdGrAca/69fm6DIVd8T9rtfDII4Q8NQbIbsKQq2VzS9eqOGYs8uaNQjcuaCq/d9H/o625aOTJX2Qoxzqw0Pw==",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.14.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.14.0.tgz",
|
||||
"integrity": "sha512-O2ok6N/bQ9NA9nJ22r/PRQQYkUe9JwfDMjBPkQ+8OwsVH4TpA5skIAM2wc0k+rni5lVbAVONVyBvgi1rF2vEPA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-rewind": "^0.5.2",
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.7",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.3.1",
|
||||
"@maplibre/mlt": "^1.1.2",
|
||||
"@maplibre/vt-pbf": "^4.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/geojson-vt": "3.2.5",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"earcut": "^3.0.2",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
|
|
@ -13457,6 +13665,15 @@
|
|||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/module-error": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz",
|
||||
|
|
@ -13502,6 +13719,12 @@
|
|||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.1",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz",
|
||||
|
|
@ -13978,6 +14201,18 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
|
|
@ -14040,6 +14275,12 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pretty-error": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
|
||||
|
|
@ -14336,6 +14577,12 @@
|
|||
"pbts": "bin/pbts"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
|
|
@ -15282,6 +15529,15 @@
|
|||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-url": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
|
||||
|
|
@ -15403,8 +15659,7 @@
|
|||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
|
|
@ -15934,6 +16189,15 @@
|
|||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
|
|
@ -16093,6 +16357,12 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tippy.js": {
|
||||
"version": "6.3.7",
|
||||
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@
|
|||
"jotai": "^2.6.0",
|
||||
"jspdf": "^2.5.2",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"maplibre-gl": "^5.14.0",
|
||||
"marked": "^15.0.4",
|
||||
"one-webcrypto": "^1.0.3",
|
||||
"openai": "^4.79.3",
|
||||
|
|
|
|||
|
|
@ -161,12 +161,17 @@ export class CloudflareAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
||||
|
||||
export class CloudflareNetworkAdapter extends NetworkAdapter {
|
||||
private workerUrl: string
|
||||
private websocket: WebSocket | null = null
|
||||
private roomId: string | null = null
|
||||
public peerId: PeerId | undefined = undefined
|
||||
public sessionId: string | null = null // Track our session ID
|
||||
private serverPeerId: PeerId | null = null // The server's peer ID for Automerge sync
|
||||
private currentDocumentId: string | null = null // Track the current document ID for sync messages
|
||||
private pendingBinaryMessages: Uint8Array[] = [] // Buffer for binary messages received before documentId is set
|
||||
private readyPromise: Promise<void>
|
||||
private readyResolve: (() => void) | null = null
|
||||
private keepAliveInterval: NodeJS.Timeout | null = null
|
||||
|
|
@ -178,6 +183,40 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
private onJsonSyncData?: (data: any) => void
|
||||
private onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void
|
||||
|
||||
// Binary sync mode - when true, uses native Automerge sync protocol
|
||||
private useBinarySync: boolean = true
|
||||
|
||||
// Connection state tracking
|
||||
private _connectionState: ConnectionState = 'disconnected'
|
||||
private connectionStateListeners: Set<(state: ConnectionState) => void> = new Set()
|
||||
private _isNetworkOnline: boolean = typeof navigator !== 'undefined' ? navigator.onLine : true
|
||||
|
||||
get connectionState(): ConnectionState {
|
||||
return this._connectionState
|
||||
}
|
||||
|
||||
get isNetworkOnline(): boolean {
|
||||
return this._isNetworkOnline
|
||||
}
|
||||
|
||||
private setConnectionState(state: ConnectionState): void {
|
||||
if (this._connectionState !== state) {
|
||||
console.log(`🔌 Connection state: ${this._connectionState} → ${state}`)
|
||||
this._connectionState = state
|
||||
this.connectionStateListeners.forEach(listener => listener(state))
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionStateChange(listener: (state: ConnectionState) => void): () => void {
|
||||
this.connectionStateListeners.add(listener)
|
||||
// Immediately call with current state
|
||||
listener(this._connectionState)
|
||||
return () => this.connectionStateListeners.delete(listener)
|
||||
}
|
||||
|
||||
private networkOnlineHandler: () => void
|
||||
private networkOfflineHandler: () => void
|
||||
|
||||
constructor(
|
||||
workerUrl: string,
|
||||
roomId?: string,
|
||||
|
|
@ -192,6 +231,29 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
this.readyPromise = new Promise((resolve) => {
|
||||
this.readyResolve = resolve
|
||||
})
|
||||
|
||||
// Set up network online/offline listeners
|
||||
this.networkOnlineHandler = () => {
|
||||
console.log('🌐 Network: online')
|
||||
this._isNetworkOnline = true
|
||||
// Trigger reconnect if we were disconnected
|
||||
if (this._connectionState === 'disconnected' && this.peerId) {
|
||||
this.setConnectionState('reconnecting')
|
||||
this.connect(this.peerId)
|
||||
}
|
||||
}
|
||||
this.networkOfflineHandler = () => {
|
||||
console.log('🌐 Network: offline')
|
||||
this._isNetworkOnline = false
|
||||
if (this._connectionState === 'connected') {
|
||||
this.setConnectionState('disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', this.networkOnlineHandler)
|
||||
window.addEventListener('offline', this.networkOfflineHandler)
|
||||
}
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
|
|
@ -202,6 +264,42 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
return this.readyPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the document ID for this adapter
|
||||
* This is needed because the server may send sync messages before we've sent any
|
||||
* @param documentId The Automerge document ID to use for incoming messages
|
||||
*/
|
||||
setDocumentId(documentId: string): void {
|
||||
console.log('📋 CloudflareAdapter: Setting documentId:', documentId)
|
||||
this.currentDocumentId = documentId
|
||||
|
||||
// Process any buffered binary messages now that we have a documentId
|
||||
if (this.pendingBinaryMessages.length > 0) {
|
||||
console.log(`📦 CloudflareAdapter: Processing ${this.pendingBinaryMessages.length} buffered binary messages`)
|
||||
const bufferedMessages = this.pendingBinaryMessages
|
||||
this.pendingBinaryMessages = []
|
||||
|
||||
for (const binaryData of bufferedMessages) {
|
||||
const message: Message = {
|
||||
type: 'sync',
|
||||
data: binaryData,
|
||||
senderId: this.serverPeerId || ('server' as PeerId),
|
||||
targetId: this.peerId || ('unknown' as PeerId),
|
||||
documentId: this.currentDocumentId as any
|
||||
}
|
||||
console.log('📥 CloudflareAdapter: Emitting buffered sync message with documentId:', this.currentDocumentId, 'size:', binaryData.byteLength)
|
||||
this.emit('message', message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current document ID
|
||||
*/
|
||||
getDocumentId(): string | null {
|
||||
return this.currentDocumentId
|
||||
}
|
||||
|
||||
connect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
|
||||
if (this.isConnecting) {
|
||||
console.log('🔌 CloudflareAdapter: Connection already in progress, skipping')
|
||||
|
|
@ -211,6 +309,9 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
// Store peerId
|
||||
this.peerId = peerId
|
||||
|
||||
// Set connection state
|
||||
this.setConnectionState(this.reconnectAttempts > 0 ? 'reconnecting' : 'connecting')
|
||||
|
||||
// Clean up existing connection
|
||||
this.cleanup()
|
||||
|
||||
|
|
@ -236,8 +337,25 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
console.log('🔌 CloudflareAdapter: WebSocket connection opened successfully')
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
this.setConnectionState('connected')
|
||||
this.readyResolve?.()
|
||||
this.startKeepAlive()
|
||||
|
||||
// CRITICAL: Emit 'ready' event for Automerge Repo
|
||||
// This tells the Repo that the network adapter is ready to sync
|
||||
this.emit('ready', { network: this })
|
||||
|
||||
// Create a server peer ID based on the room
|
||||
// The server acts as a "hub" peer that all clients sync with
|
||||
this.serverPeerId = `server-${this.roomId}` as PeerId
|
||||
|
||||
// CRITICAL: Emit 'peer-candidate' to announce the server as a sync peer
|
||||
// This tells the Automerge Repo there's a peer to sync documents with
|
||||
console.log('🔌 CloudflareAdapter: Announcing server peer for Automerge sync:', this.serverPeerId)
|
||||
this.emit('peer-candidate', {
|
||||
peerId: this.serverPeerId,
|
||||
peerMetadata: { storageId: undefined, isEphemeral: false }
|
||||
})
|
||||
}
|
||||
|
||||
this.websocket.onmessage = (event) => {
|
||||
|
|
@ -245,26 +363,46 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
// Automerge's native protocol uses binary messages
|
||||
// We need to handle both binary and text messages
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
console.log('🔌 CloudflareAdapter: Received binary message (Automerge protocol)')
|
||||
console.log('🔌 CloudflareAdapter: Received binary message (Automerge protocol)', event.data.byteLength, 'bytes')
|
||||
// Handle binary Automerge sync messages - convert ArrayBuffer to Uint8Array
|
||||
// Automerge Repo expects binary sync messages as Uint8Array
|
||||
// CRITICAL: senderId should be the SERVER (where the message came from)
|
||||
// targetId should be US (where the message is going to)
|
||||
// CRITICAL: Include documentId for Automerge Repo to route the message correctly
|
||||
const binaryData = new Uint8Array(event.data)
|
||||
if (!this.currentDocumentId) {
|
||||
console.log('📦 CloudflareAdapter: Buffering binary sync message (no documentId yet), size:', binaryData.byteLength)
|
||||
// Buffer for later processing when we have a documentId
|
||||
this.pendingBinaryMessages.push(binaryData)
|
||||
return
|
||||
}
|
||||
const message: Message = {
|
||||
type: 'sync',
|
||||
data: new Uint8Array(event.data),
|
||||
senderId: this.peerId || ('unknown' as PeerId),
|
||||
targetId: this.peerId || ('unknown' as PeerId)
|
||||
data: binaryData,
|
||||
senderId: this.serverPeerId || ('server' as PeerId),
|
||||
targetId: this.peerId || ('unknown' as PeerId),
|
||||
documentId: this.currentDocumentId as any // DocumentId type
|
||||
}
|
||||
console.log('📥 CloudflareAdapter: Emitting sync message with documentId:', this.currentDocumentId)
|
||||
this.emit('message', message)
|
||||
} else if (event.data instanceof Blob) {
|
||||
// Handle Blob messages (convert to Uint8Array)
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
console.log('🔌 CloudflareAdapter: Received Blob message, converted to Uint8Array')
|
||||
console.log('🔌 CloudflareAdapter: Received Blob message, converted to Uint8Array', buffer.byteLength, 'bytes')
|
||||
const binaryData = new Uint8Array(buffer)
|
||||
if (!this.currentDocumentId) {
|
||||
console.log('📦 CloudflareAdapter: Buffering Blob sync message (no documentId yet), size:', binaryData.byteLength)
|
||||
this.pendingBinaryMessages.push(binaryData)
|
||||
return
|
||||
}
|
||||
const message: Message = {
|
||||
type: 'sync',
|
||||
data: new Uint8Array(buffer),
|
||||
senderId: this.peerId || ('unknown' as PeerId),
|
||||
targetId: this.peerId || ('unknown' as PeerId)
|
||||
data: binaryData,
|
||||
senderId: this.serverPeerId || ('server' as PeerId),
|
||||
targetId: this.peerId || ('unknown' as PeerId),
|
||||
documentId: this.currentDocumentId as any
|
||||
}
|
||||
console.log('📥 CloudflareAdapter: Emitting Blob sync message with documentId:', this.currentDocumentId)
|
||||
this.emit('message', message)
|
||||
})
|
||||
} else {
|
||||
|
|
@ -363,10 +501,10 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
url: wsUrl,
|
||||
reconnectAttempts: this.reconnectAttempts
|
||||
})
|
||||
|
||||
|
||||
this.isConnecting = false
|
||||
this.stopKeepAlive()
|
||||
|
||||
|
||||
// Log specific error codes for debugging
|
||||
if (event.code === 1005) {
|
||||
console.error('❌ WebSocket closed with code 1005 (No Status Received) - this usually indicates a connection issue or idle timeout')
|
||||
|
|
@ -376,11 +514,19 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
console.error('❌ WebSocket closed with code 1011 (Server Error) - server encountered an error')
|
||||
} else if (event.code === 1000) {
|
||||
console.log('✅ WebSocket closed normally (code 1000)')
|
||||
this.setConnectionState('disconnected')
|
||||
return // Don't reconnect on normal closure
|
||||
}
|
||||
|
||||
|
||||
// Set state based on whether we'll try to reconnect
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts && this._isNetworkOnline) {
|
||||
this.setConnectionState('reconnecting')
|
||||
} else {
|
||||
this.setConnectionState('disconnected')
|
||||
}
|
||||
|
||||
this.emit('close')
|
||||
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
this.scheduleReconnect(peerId, peerMetadata)
|
||||
}
|
||||
|
|
@ -413,10 +559,21 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
dataLength: (message as any).data?.byteLength || (message as any).data?.length,
|
||||
documentId: (message as any).documentId,
|
||||
hasTargetId: !!message.targetId,
|
||||
hasSenderId: !!message.senderId
|
||||
hasSenderId: !!message.senderId,
|
||||
useBinarySync: this.useBinarySync
|
||||
})
|
||||
}
|
||||
|
||||
// CRITICAL: Capture documentId from outgoing sync messages
|
||||
// This allows us to use it for incoming messages from the server
|
||||
if (message.type === 'sync' && (message as any).documentId) {
|
||||
const docId = (message as any).documentId
|
||||
if (this.currentDocumentId !== docId) {
|
||||
console.log('📋 CloudflareAdapter: Captured documentId from outgoing sync:', docId)
|
||||
this.currentDocumentId = docId
|
||||
}
|
||||
}
|
||||
|
||||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
// Check if this is a binary sync message from Automerge Repo
|
||||
if (message.type === 'sync' && (message as any).data instanceof ArrayBuffer) {
|
||||
|
|
@ -427,14 +584,16 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
})
|
||||
// Send binary data directly for Automerge's native sync protocol
|
||||
this.websocket.send((message as any).data)
|
||||
return // CRITICAL: Don't fall through to JSON send
|
||||
} else if (message.type === 'sync' && (message as any).data instanceof Uint8Array) {
|
||||
console.log('📤 CloudflareAdapter: Sending Uint8Array sync message (Automerge protocol)', {
|
||||
dataLength: (message as any).data.length,
|
||||
documentId: (message as any).documentId,
|
||||
targetId: message.targetId
|
||||
})
|
||||
// Convert Uint8Array to ArrayBuffer and send
|
||||
this.websocket.send((message as any).data.buffer)
|
||||
// Send Uint8Array directly - WebSocket accepts Uint8Array
|
||||
this.websocket.send((message as any).data)
|
||||
return // CRITICAL: Don't fall through to JSON send
|
||||
} else {
|
||||
// Handle text-based messages (backward compatibility and control messages)
|
||||
// Only log non-presence messages
|
||||
|
|
@ -473,6 +632,15 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
disconnect(): void {
|
||||
this.cleanup()
|
||||
this.roomId = null
|
||||
this.setConnectionState('disconnected')
|
||||
|
||||
// Clean up network listeners
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('online', this.networkOnlineHandler)
|
||||
window.removeEventListener('offline', this.networkOfflineHandler)
|
||||
}
|
||||
this.connectionStateListeners.clear()
|
||||
|
||||
this.emit('close')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -315,6 +315,19 @@ function sanitizeRecord(record: TLRecord): TLRecord {
|
|||
(sanitized.props as any).richText = { content: [], type: 'doc' }
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: For text shapes, preserve richText property (required for text shapes)
|
||||
// Text shapes store their content in props.richText, not props.text
|
||||
if (sanitized.type === 'text') {
|
||||
// CRITICAL: Use the extracted richText value if available, otherwise create default
|
||||
if (richTextValue !== undefined) {
|
||||
// Clean NaN values to prevent SVG export errors
|
||||
(sanitized.props as any).richText = cleanRichTextNaN(richTextValue)
|
||||
} else {
|
||||
// Text shapes require richText - create default if missing
|
||||
(sanitized.props as any).richText = { content: [], type: 'doc' }
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: For ObsNote shapes, ensure all props are preserved (title, content, tags, etc.)
|
||||
if (sanitized.type === 'ObsNote') {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,8 @@ import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
|
|||
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
|
||||
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
|
||||
import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil"
|
||||
// Location shape removed - no longer needed
|
||||
// Open Mapping - OSM map shape for geographic visualization
|
||||
import { MapShape } from "@/shapes/MapShapeUtil"
|
||||
|
||||
export function useAutomergeStoreV2({
|
||||
handle,
|
||||
|
|
@ -163,6 +164,7 @@ export function useAutomergeStoreV2({
|
|||
VideoGenShape,
|
||||
MultmuxShape,
|
||||
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
|
||||
MapShape, // Open Mapping - OSM map shape
|
||||
]
|
||||
|
||||
// CRITICAL: Explicitly list ALL custom shape types to ensure they're registered
|
||||
|
|
@ -185,6 +187,7 @@ export function useAutomergeStoreV2({
|
|||
'VideoGen',
|
||||
'Multmux',
|
||||
'MycelialIntelligence', // Deprecated - kept for backwards compatibility
|
||||
'Map', // Open Mapping - OSM map shape
|
||||
]
|
||||
|
||||
// Build schema with explicit entries for all custom shapes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useEffect, useState, useCallback, useRef } from "react"
|
||||
import { TLStoreSnapshot, InstancePresenceRecordType, getIndexAbove, IndexKey } from "@tldraw/tldraw"
|
||||
import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
|
||||
import { CloudflareNetworkAdapter, ConnectionState } from "./CloudflareAdapter"
|
||||
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
|
||||
import { TLStoreWithStatus } from "@tldraw/tldraw"
|
||||
import { Repo, parseAutomergeUrl, stringifyAutomergeUrl, AutomergeUrl, DocumentId } from "@automerge/automerge-repo"
|
||||
|
|
@ -114,9 +114,14 @@ interface AutomergeSyncConfig {
|
|||
}
|
||||
}
|
||||
|
||||
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: DocHandle<any> | null; presence: ReturnType<typeof useAutomergePresence> } {
|
||||
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & {
|
||||
handle: DocHandle<any> | null;
|
||||
presence: ReturnType<typeof useAutomergePresence>;
|
||||
connectionState: ConnectionState;
|
||||
isNetworkOnline: boolean;
|
||||
} {
|
||||
const { uri, user } = config
|
||||
|
||||
|
||||
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
|
||||
const roomId = useMemo(() => {
|
||||
const match = uri.match(/\/connect\/([^\/]+)$/)
|
||||
|
|
@ -130,79 +135,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
const [handle, setHandle] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting')
|
||||
const [isNetworkOnline, setIsNetworkOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||
const handleRef = useRef<any>(null)
|
||||
const storeRef = useRef<any>(null)
|
||||
const adapterRef = useRef<any>(null)
|
||||
const lastSentHashRef = useRef<string | null>(null)
|
||||
const isMouseActiveRef = useRef<boolean>(false)
|
||||
const pendingSaveRef = useRef<boolean>(false)
|
||||
const saveFunctionRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// Generate a fast hash of the document state for change detection
|
||||
// OPTIMIZED: Avoid expensive JSON.stringify, use lightweight checksums instead
|
||||
const generateDocHash = useCallback((doc: any): string => {
|
||||
if (!doc || !doc.store) return ''
|
||||
const storeData = doc.store || {}
|
||||
const storeKeys = Object.keys(storeData).sort()
|
||||
|
||||
// Fast hash using record IDs and lightweight checksums
|
||||
// Instead of JSON.stringify, use a combination of ID, type, and key property values
|
||||
let hash = 0
|
||||
for (const key of storeKeys) {
|
||||
// Skip ephemeral records
|
||||
if (key.startsWith('instance:') ||
|
||||
key.startsWith('instance_page_state:') ||
|
||||
key.startsWith('instance_presence:') ||
|
||||
key.startsWith('camera:') ||
|
||||
key.startsWith('pointer:')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const record = storeData[key]
|
||||
if (!record) continue
|
||||
|
||||
// Use lightweight hash: ID + typeName + type (if shape) + key properties
|
||||
let recordHash = key
|
||||
if (record.typeName) recordHash += record.typeName
|
||||
if (record.type) recordHash += record.type
|
||||
|
||||
// For shapes, include x, y, w, h for position/size changes
|
||||
// Also include text content for shapes that have it (Markdown, ObsNote, etc.)
|
||||
if (record.typeName === 'shape') {
|
||||
if (typeof record.x === 'number') recordHash += `x${record.x}`
|
||||
if (typeof record.y === 'number') recordHash += `y${record.y}`
|
||||
if (typeof record.props?.w === 'number') recordHash += `w${record.props.w}`
|
||||
if (typeof record.props?.h === 'number') recordHash += `h${record.props.h}`
|
||||
// CRITICAL: Include text content in hash for Markdown and similar shapes
|
||||
// This ensures text changes trigger R2 persistence
|
||||
if (typeof record.props?.text === 'string' && record.props.text.length > 0) {
|
||||
// Include text length and a sample of content for change detection
|
||||
recordHash += `t${record.props.text.length}`
|
||||
// Include first 100 chars and last 50 chars to detect changes anywhere in the text
|
||||
recordHash += record.props.text.substring(0, 100)
|
||||
if (record.props.text.length > 150) {
|
||||
recordHash += record.props.text.substring(record.props.text.length - 50)
|
||||
}
|
||||
}
|
||||
// Also include content for ObsNote shapes
|
||||
if (typeof record.props?.content === 'string' && record.props.content.length > 0) {
|
||||
recordHash += `c${record.props.content.length}`
|
||||
recordHash += record.props.content.substring(0, 100)
|
||||
if (record.props.content.length > 150) {
|
||||
recordHash += record.props.content.substring(record.props.content.length - 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple hash of the record string
|
||||
for (let i = 0; i < recordHash.length; i++) {
|
||||
const char = recordHash.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash
|
||||
}
|
||||
}
|
||||
return hash.toString(36)
|
||||
}, [])
|
||||
|
||||
// Update refs when handle/store changes
|
||||
useEffect(() => {
|
||||
|
|
@ -360,6 +297,15 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
return { repo, adapter, storageAdapter }
|
||||
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate])
|
||||
|
||||
// Subscribe to connection state changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = adapter.onConnectionStateChange((state) => {
|
||||
setConnectionState(state)
|
||||
setIsNetworkOnline(adapter.isNetworkOnline)
|
||||
})
|
||||
return unsubscribe
|
||||
}, [adapter])
|
||||
|
||||
// Initialize Automerge document handle
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
|
@ -520,6 +466,14 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
console.log(`Automerge handle ready: ${finalStoreKeys} records, ${finalShapeCount} shapes (loaded from ${loadedFromLocal ? 'IndexedDB' : 'server/new'})`)
|
||||
|
||||
// CRITICAL: Set the documentId on the adapter BEFORE setHandle
|
||||
// This ensures the adapter can properly route incoming binary sync messages
|
||||
// The server may send sync messages immediately after connection, before we send anything
|
||||
if (handle.url) {
|
||||
adapter.setDocumentId(handle.url)
|
||||
console.log(`📋 Set documentId on adapter: ${handle.url}`)
|
||||
}
|
||||
|
||||
setHandle(handle)
|
||||
setIsLoading(false)
|
||||
} catch (error) {
|
||||
|
|
@ -541,318 +495,61 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
}
|
||||
}, [repo, adapter, roomId, workerUrl])
|
||||
|
||||
// Track mouse state to prevent persistence during active mouse interactions
|
||||
useEffect(() => {
|
||||
const handleMouseDown = () => {
|
||||
isMouseActiveRef.current = true
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isMouseActiveRef.current = false
|
||||
// If there was a pending save, schedule it now that mouse is released
|
||||
if (pendingSaveRef.current) {
|
||||
pendingSaveRef.current = false
|
||||
// Trigger save after a short delay to ensure mouse interaction is fully complete
|
||||
setTimeout(() => {
|
||||
// The save will be triggered by the next scheduled save or change event
|
||||
// We just need to ensure the mouse state is cleared
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
// Also track touch events for mobile
|
||||
const handleTouchStart = () => {
|
||||
isMouseActiveRef.current = true
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
isMouseActiveRef.current = false
|
||||
if (pendingSaveRef.current) {
|
||||
pendingSaveRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners to document to catch all mouse interactions
|
||||
document.addEventListener('mousedown', handleMouseDown, { capture: true })
|
||||
document.addEventListener('mouseup', handleMouseUp, { capture: true })
|
||||
document.addEventListener('touchstart', handleTouchStart, { capture: true })
|
||||
document.addEventListener('touchend', handleTouchEnd, { capture: true })
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown, { capture: true })
|
||||
document.removeEventListener('mouseup', handleMouseUp, { capture: true })
|
||||
document.removeEventListener('touchstart', handleTouchStart, { capture: true })
|
||||
document.removeEventListener('touchend', handleTouchEnd, { capture: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls)
|
||||
// CRITICAL: This ensures new shapes are persisted to R2
|
||||
// BINARY CRDT SYNC: The Automerge Repo now handles sync automatically via the NetworkAdapter
|
||||
// The NetworkAdapter sends binary sync messages when documents change
|
||||
// Local persistence is handled by IndexedDB via the storage adapter
|
||||
// Server persistence is handled by the worker receiving binary sync messages
|
||||
//
|
||||
// We keep a lightweight change logger for debugging, but no HTTP POST sync
|
||||
useEffect(() => {
|
||||
if (!handle) return
|
||||
|
||||
let saveTimeout: NodeJS.Timeout
|
||||
|
||||
const saveDocumentToWorker = async () => {
|
||||
// CRITICAL: Don't save while mouse is active - this prevents interference with mouse interactions
|
||||
if (isMouseActiveRef.current) {
|
||||
console.log('⏸️ Deferring persistence - mouse is active')
|
||||
pendingSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = handle.doc()
|
||||
if (!doc || !doc.store) {
|
||||
console.log("🔍 No document to save yet")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate hash of current document state
|
||||
const currentHash = generateDocHash(doc)
|
||||
const lastHash = lastSentHashRef.current
|
||||
|
||||
// Skip save if document hasn't changed
|
||||
if (currentHash === lastHash) {
|
||||
console.log('⏭️ Skipping persistence - document unchanged (hash matches)')
|
||||
return
|
||||
}
|
||||
|
||||
// OPTIMIZED: Defer JSON.stringify to avoid blocking main thread
|
||||
// Use requestIdleCallback to serialize when browser is idle
|
||||
const storeKeys = Object.keys(doc.store).length
|
||||
|
||||
// Defer expensive serialization to avoid blocking
|
||||
const serializedDoc = await new Promise<string>((resolve, reject) => {
|
||||
const serialize = () => {
|
||||
try {
|
||||
// Direct JSON.stringify - browser optimizes this internally
|
||||
// The key is doing it in an idle callback to not block interactions
|
||||
const json = JSON.stringify(doc)
|
||||
resolve(json)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Use requestIdleCallback if available to serialize when browser is idle
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(serialize, { timeout: 200 })
|
||||
} else {
|
||||
// Fallback: use setTimeout to defer to next event loop tick
|
||||
setTimeout(serialize, 0)
|
||||
}
|
||||
})
|
||||
|
||||
// CRITICAL: Always log saves to help debug persistence issues
|
||||
const shapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
console.log(`💾 Persisting document to worker for R2 storage: ${storeKeys} records, ${shapeCount} shapes`)
|
||||
|
||||
// Send document state to worker via POST /room/:roomId
|
||||
// This updates the worker's currentDoc so it can be persisted to R2
|
||||
const response = await fetch(`${workerUrl}/room/${roomId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: serializedDoc,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save to worker: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Update last sent hash only after successful save
|
||||
lastSentHashRef.current = currentHash
|
||||
pendingSaveRef.current = false
|
||||
// CRITICAL: Always log successful saves
|
||||
const finalShapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
console.log(`✅ Successfully sent document state to worker for persistence (${finalShapeCount} shapes)`)
|
||||
} catch (error) {
|
||||
console.error('❌ Error saving document to worker:', error)
|
||||
pendingSaveRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// Store save function reference for mouse release handler
|
||||
saveFunctionRef.current = saveDocumentToWorker
|
||||
|
||||
const scheduleSave = () => {
|
||||
// Clear existing timeout
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
|
||||
// CRITICAL: Check if mouse is active before scheduling save
|
||||
if (isMouseActiveRef.current) {
|
||||
console.log('⏸️ Deferring save scheduling - mouse is active')
|
||||
pendingSaveRef.current = true
|
||||
// Schedule a check for when mouse is released
|
||||
const checkMouseState = () => {
|
||||
if (!isMouseActiveRef.current && pendingSaveRef.current) {
|
||||
pendingSaveRef.current = false
|
||||
// Mouse is released, schedule the save now
|
||||
requestAnimationFrame(() => {
|
||||
saveTimeout = setTimeout(saveDocumentToWorker, 3000)
|
||||
})
|
||||
} else if (isMouseActiveRef.current) {
|
||||
// Mouse still active, check again in 100ms
|
||||
setTimeout(checkMouseState, 100)
|
||||
}
|
||||
}
|
||||
setTimeout(checkMouseState, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Use requestIdleCallback if available to defer saves until browser is idle
|
||||
// This prevents saves from interrupting active interactions
|
||||
const schedule = () => {
|
||||
// Schedule save with a debounce (3 seconds) to batch rapid changes
|
||||
saveTimeout = setTimeout(saveDocumentToWorker, 3000)
|
||||
}
|
||||
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(schedule, { timeout: 2000 })
|
||||
} else {
|
||||
requestAnimationFrame(schedule)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for changes to the Automerge document
|
||||
// Listen for changes to log sync activity (debugging only)
|
||||
const changeHandler = (payload: any) => {
|
||||
const patchCount = payload.patches?.length || 0
|
||||
|
||||
if (!patchCount) {
|
||||
// No patches, nothing to save
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: If mouse is active, defer all processing to avoid blocking mouse interactions
|
||||
if (isMouseActiveRef.current) {
|
||||
// Just mark that we have pending changes, process them when mouse is released
|
||||
pendingSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
// Process patches asynchronously to avoid blocking
|
||||
requestAnimationFrame(() => {
|
||||
// Double-check mouse state after animation frame
|
||||
if (isMouseActiveRef.current) {
|
||||
pendingSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out ephemeral record changes - these shouldn't trigger persistence
|
||||
const ephemeralIdPatterns = [
|
||||
'instance:',
|
||||
'instance_page_state:',
|
||||
'instance_presence:',
|
||||
'camera:',
|
||||
'pointer:'
|
||||
]
|
||||
|
||||
// Quick check for ephemeral changes (lightweight)
|
||||
const hasOnlyEphemeralChanges = payload.patches.every((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
if (!id || typeof id !== 'string') return false
|
||||
return ephemeralIdPatterns.some(pattern => id.startsWith(pattern))
|
||||
})
|
||||
|
||||
// If all patches are for ephemeral records, skip persistence
|
||||
if (hasOnlyEphemeralChanges) {
|
||||
console.log('🚫 Skipping persistence - only ephemeral changes detected:', {
|
||||
patchCount
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if patches contain shape changes (lightweight check)
|
||||
const hasShapeChanges = payload.patches?.some((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
})
|
||||
|
||||
if (hasShapeChanges) {
|
||||
// Check if ALL patches are only position updates (x/y) for pinned-to-view shapes
|
||||
// These shouldn't trigger persistence since they're just keeping the shape in the same screen position
|
||||
// NOTE: We defer doc access to avoid blocking, but do lightweight path checks
|
||||
const allPositionUpdates = payload.patches.every((p: any) => {
|
||||
const shapeId = p.path?.[1]
|
||||
|
||||
// If this is not a shape patch, it's not a position update
|
||||
if (!shapeId || typeof shapeId !== 'string' || !shapeId.startsWith('shape:')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this is a position update (x or y coordinate)
|
||||
// Path format: ['store', 'shape:xxx', 'x'] or ['store', 'shape:xxx', 'y']
|
||||
const pathLength = p.path?.length || 0
|
||||
return pathLength === 3 && (p.path[2] === 'x' || p.path[2] === 'y')
|
||||
})
|
||||
|
||||
// If all patches are position updates, check if they're for pinned shapes
|
||||
// This requires doc access, so we defer it slightly
|
||||
if (allPositionUpdates && payload.patches.length > 0) {
|
||||
// Defer expensive doc access check
|
||||
setTimeout(() => {
|
||||
if (isMouseActiveRef.current) {
|
||||
pendingSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
const doc = handle.doc()
|
||||
const allPinned = payload.patches.every((p: any) => {
|
||||
const shapeId = p.path?.[1]
|
||||
if (!shapeId || typeof shapeId !== 'string' || !shapeId.startsWith('shape:')) {
|
||||
return false
|
||||
}
|
||||
if (doc?.store?.[shapeId]) {
|
||||
const shape = doc.store[shapeId]
|
||||
return shape?.props?.pinnedToView === true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (allPinned) {
|
||||
console.log('🚫 Skipping persistence - only pinned-to-view position updates detected:', {
|
||||
patchCount: payload.patches.length
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Not all pinned, schedule save
|
||||
scheduleSave()
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const shapePatches = payload.patches.filter((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
})
|
||||
|
||||
// CRITICAL: Always log shape changes to debug persistence
|
||||
if (shapePatches.length > 0) {
|
||||
console.log('🔍 Automerge document changed with shape patches:', {
|
||||
patchCount: patchCount,
|
||||
shapePatches: shapePatches.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule save to worker for persistence (only for non-ephemeral changes)
|
||||
scheduleSave()
|
||||
})
|
||||
}
|
||||
|
||||
handle.on('change', changeHandler)
|
||||
|
||||
// Don't save immediately on mount - only save when actual changes occur
|
||||
// The initial document load from server is already persisted, so we don't need to re-persist it
|
||||
if (!patchCount) return
|
||||
|
||||
// Filter out ephemeral record changes for logging
|
||||
const ephemeralIdPatterns = [
|
||||
'instance:',
|
||||
'instance_page_state:',
|
||||
'instance_presence:',
|
||||
'camera:',
|
||||
'pointer:'
|
||||
]
|
||||
|
||||
const hasOnlyEphemeralChanges = payload.patches.every((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
if (!id || typeof id !== 'string') return false
|
||||
return ephemeralIdPatterns.some(pattern => id.startsWith(pattern))
|
||||
})
|
||||
|
||||
if (hasOnlyEphemeralChanges) {
|
||||
// Don't log ephemeral changes
|
||||
return
|
||||
}
|
||||
|
||||
// Log significant changes for debugging
|
||||
const shapePatches = payload.patches.filter((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
})
|
||||
|
||||
if (shapePatches.length > 0) {
|
||||
console.log('🔄 Automerge document changed (binary sync will propagate):', {
|
||||
patchCount: patchCount,
|
||||
shapePatches: shapePatches.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handle.on('change', changeHandler)
|
||||
|
||||
return () => {
|
||||
handle.off('change', changeHandler)
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
}
|
||||
}, [handle, roomId, workerUrl, generateDocHash])
|
||||
}, [handle])
|
||||
|
||||
// Generate a unique color for each user based on their userId
|
||||
const generateUserColor = (userId: string): string => {
|
||||
|
|
@ -910,6 +607,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
return {
|
||||
...storeWithStatus,
|
||||
handle,
|
||||
presence
|
||||
presence,
|
||||
connectionState,
|
||||
isNetworkOnline
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { ConnectionState } from '../automerge/CloudflareAdapter'
|
||||
|
||||
interface ConnectionStatusIndicatorProps {
|
||||
connectionState: ConnectionState
|
||||
isNetworkOnline: boolean
|
||||
}
|
||||
|
||||
export function ConnectionStatusIndicator({
|
||||
connectionState,
|
||||
isNetworkOnline
|
||||
}: ConnectionStatusIndicatorProps) {
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
// Determine if we're truly offline (no network OR disconnected for a while)
|
||||
const isOffline = !isNetworkOnline || connectionState === 'disconnected'
|
||||
const isReconnecting = connectionState === 'reconnecting' || connectionState === 'connecting'
|
||||
|
||||
// Don't show anything when connected and online
|
||||
useEffect(() => {
|
||||
if (connectionState === 'connected' && isNetworkOnline) {
|
||||
// Fade out
|
||||
setIsVisible(false)
|
||||
setShowDetails(false)
|
||||
} else {
|
||||
// Fade in
|
||||
setIsVisible(true)
|
||||
}
|
||||
}, [connectionState, isNetworkOnline])
|
||||
|
||||
if (!isVisible && connectionState === 'connected' && isNetworkOnline) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getStatusInfo = () => {
|
||||
if (!isNetworkOnline) {
|
||||
return {
|
||||
label: 'Working Offline',
|
||||
color: '#8b5cf6', // Purple - calm, not alarming
|
||||
icon: '🍄',
|
||||
pulse: false,
|
||||
description: 'Your data is safe and encrypted locally',
|
||||
detailedMessage: `Your canvas is stored securely in your browser using encrypted local storage. All changes are preserved with your personal encryption key. When you reconnect, your work will automatically sync with the shared canvas — no data will be lost.`,
|
||||
}
|
||||
}
|
||||
|
||||
switch (connectionState) {
|
||||
case 'connecting':
|
||||
return {
|
||||
label: 'Connecting',
|
||||
color: '#f59e0b', // amber
|
||||
icon: '🌱',
|
||||
pulse: true,
|
||||
description: 'Establishing secure connection...',
|
||||
detailedMessage: 'Connecting to the collaborative canvas. Your local changes are safely stored.',
|
||||
}
|
||||
case 'reconnecting':
|
||||
return {
|
||||
label: 'Reconnecting',
|
||||
color: '#f59e0b', // amber
|
||||
icon: '🔄',
|
||||
pulse: true,
|
||||
description: 'Re-establishing connection...',
|
||||
detailedMessage: 'Connection interrupted. Attempting to reconnect. All your changes are saved locally and will sync automatically once the connection is restored.',
|
||||
}
|
||||
case 'disconnected':
|
||||
return {
|
||||
label: 'Disconnected',
|
||||
color: '#8b5cf6', // Purple
|
||||
icon: '🍄',
|
||||
pulse: false,
|
||||
description: 'Working in local mode',
|
||||
detailedMessage: `Your canvas is stored securely in your browser using encrypted local storage. All changes are preserved with your personal encryption key. When connectivity is restored, your work will automatically merge with the shared canvas.`,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const status = getStatusInfo()
|
||||
if (!status) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: showDetails ? '12px 16px' : '10px 16px',
|
||||
backgroundColor: 'rgba(30, 30, 30, 0.95)',
|
||||
color: 'white',
|
||||
borderRadius: showDetails ? '16px' : '24px',
|
||||
fontSize: '14px',
|
||||
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
maxWidth: showDetails ? '380px' : '320px',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
animation: status.pulse ? 'gentlePulse 3s infinite' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Status indicator dot */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: '18px' }}>{status.icon}</span>
|
||||
<span
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: status.color,
|
||||
boxShadow: `0 0 8px ${status.color}`,
|
||||
animation: status.pulse ? 'blink 1.5s infinite' : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontWeight: 600,
|
||||
color: status.color,
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span style={{
|
||||
opacity: 0.7,
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{status.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Detailed message when expanded */}
|
||||
{showDetails && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
opacity: 0.85,
|
||||
marginTop: '6px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||
}}>
|
||||
{status.detailedMessage}
|
||||
|
||||
{/* Data sovereignty badges */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginTop: '10px',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: '#a78bfa',
|
||||
}}>
|
||||
🔐 Encrypted Locally
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: '#6ee7b7',
|
||||
}}>
|
||||
💾 Auto-Saved
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: '#93c5fd',
|
||||
}}>
|
||||
🔄 Will Auto-Sync
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand indicator */}
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes gentlePulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255,255,255,0.1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255,255,255,0.15);
|
||||
}
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* CollaborativeMap - Complete example of map with location presence
|
||||
*
|
||||
* This component demonstrates how to integrate the MapCanvas with
|
||||
* real-time location presence. Location sharing is OPT-IN - users
|
||||
* must explicitly click to share their location.
|
||||
*
|
||||
* Usage in your app:
|
||||
* ```tsx
|
||||
* import { CollaborativeMap } from '@/open-mapping/components/CollaborativeMap';
|
||||
*
|
||||
* function MyPage() {
|
||||
* return (
|
||||
* <CollaborativeMap
|
||||
* roomId="my-room-123"
|
||||
* user={{
|
||||
* pubKey: userPublicKey,
|
||||
* privKey: userPrivateKey,
|
||||
* displayName: 'Alice',
|
||||
* color: '#3b82f6',
|
||||
* }}
|
||||
* broadcastFn={(data) => myAutomergeAdapter.broadcast(data)}
|
||||
* onBroadcastReceived={(handler) => {
|
||||
* return myAutomergeAdapter.onMessage((msg) => {
|
||||
* if (msg.type === 'location-presence') handler(msg.payload);
|
||||
* });
|
||||
* }}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { MapCanvas } from './MapCanvas';
|
||||
import { PresenceList } from '../presence/PresenceLayer';
|
||||
import { useLocationPresence } from '../presence/useLocationPresence';
|
||||
import type { PresenceView, PresenceBroadcast } from '../presence/types';
|
||||
import type { MapViewport, Coordinate } from '../types';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface CollaborativeMapProps {
|
||||
/** Room/channel ID for presence */
|
||||
roomId: string;
|
||||
|
||||
/** User identity */
|
||||
user: {
|
||||
pubKey: string;
|
||||
privKey: string;
|
||||
displayName: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
/** Function to broadcast data to other clients */
|
||||
broadcastFn: (data: any) => void;
|
||||
|
||||
/** Subscribe to incoming broadcasts - returns unsubscribe function */
|
||||
onBroadcastReceived: (handler: (broadcast: PresenceBroadcast) => void) => () => void;
|
||||
|
||||
/** Initial map viewport */
|
||||
initialViewport?: MapViewport;
|
||||
|
||||
/** Show the presence sidebar */
|
||||
showPresenceList?: boolean;
|
||||
|
||||
/** Custom map style */
|
||||
mapStyle?: string;
|
||||
|
||||
/** Callback when map is clicked */
|
||||
onMapClick?: (coordinate: Coordinate) => void;
|
||||
|
||||
/** Callback when a user's presence is clicked */
|
||||
onUserClick?: (view: PresenceView) => void;
|
||||
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function CollaborativeMap({
|
||||
roomId,
|
||||
user,
|
||||
broadcastFn,
|
||||
onBroadcastReceived,
|
||||
initialViewport = { center: [-122.4194, 37.7749], zoom: 12 }, // SF default
|
||||
showPresenceList = true,
|
||||
mapStyle,
|
||||
onMapClick,
|
||||
onUserClick,
|
||||
className,
|
||||
}: CollaborativeMapProps) {
|
||||
const [viewport, setViewport] = useState<MapViewport>(initialViewport);
|
||||
|
||||
// Initialize presence system (location is OFF by default)
|
||||
const presence = useLocationPresence({
|
||||
channelId: roomId,
|
||||
user,
|
||||
broadcastFn,
|
||||
// autoStartLocation: false (default - OPT-IN required)
|
||||
});
|
||||
|
||||
// Handle incoming broadcasts
|
||||
useEffect(() => {
|
||||
const unsubscribe = onBroadcastReceived((broadcast) => {
|
||||
presence.handleBroadcast(broadcast);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [onBroadcastReceived, presence.handleBroadcast]);
|
||||
|
||||
// Handle presence click - fly to their location
|
||||
const handlePresenceClick = useCallback((view: PresenceView) => {
|
||||
if (view.location) {
|
||||
setViewport({
|
||||
...viewport,
|
||||
center: [view.location.center.longitude, view.location.center.latitude],
|
||||
zoom: Math.max(viewport.zoom, 14),
|
||||
});
|
||||
}
|
||||
onUserClick?.(view);
|
||||
}, [viewport, onUserClick]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`collaborative-map ${className ?? ''}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Main Map */}
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<MapCanvas
|
||||
viewport={viewport}
|
||||
onViewportChange={setViewport}
|
||||
onMapClick={onMapClick}
|
||||
style={mapStyle}
|
||||
presenceViews={presence.views}
|
||||
showPresenceUncertainty={true}
|
||||
onPresenceClick={handlePresenceClick}
|
||||
/>
|
||||
|
||||
{/* Location sharing controls - bottom left */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 40,
|
||||
left: 10,
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<LocationSharingToggle
|
||||
isSharing={presence.isSharing}
|
||||
onStart={presence.startSharing}
|
||||
onStop={presence.stopSharing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Online count badge - top left */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
zIndex: 1000,
|
||||
padding: '4px 12px',
|
||||
backgroundColor: 'rgba(0,0,0,0.75)',
|
||||
borderRadius: 16,
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{presence.onlineCount + 1} online
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presence sidebar */}
|
||||
{showPresenceList && (
|
||||
<div
|
||||
style={{
|
||||
width: 280,
|
||||
backgroundColor: '#1f2937',
|
||||
borderLeft: '1px solid #374151',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderBottom: '1px solid #374151',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
People ({presence.views.length})
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 8 }}>
|
||||
{/* Self */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.2)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: user.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{user.displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'white', fontWeight: 500 }}>{user.displayName} (you)</div>
|
||||
<div style={{ color: '#9ca3af', fontSize: 12 }}>
|
||||
{presence.isSharing ? 'Sharing location' : 'Location hidden'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Others */}
|
||||
<PresenceList
|
||||
views={presence.views}
|
||||
onUserClick={handlePresenceClick}
|
||||
onTrustLevelChange={presence.setTrustLevel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Location Sharing Toggle Button
|
||||
// =============================================================================
|
||||
|
||||
interface LocationSharingToggleProps {
|
||||
isSharing: boolean;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
function LocationSharingToggle({ isSharing, onStart, onStop }: LocationSharingToggleProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={isSharing ? onStop : onStart}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '10px 16px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
backgroundColor: isSharing ? '#ef4444' : '#22c55e',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<LocationIcon />
|
||||
{isSharing ? 'Stop Sharing' : 'Share Location'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
<path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollaborativeMap;
|
||||
|
|
@ -1,72 +1,244 @@
|
|||
/**
|
||||
* MapCanvas - Main map component that integrates with tldraw canvas
|
||||
*
|
||||
* Renders a MapLibre GL JS map as a layer within the tldraw canvas,
|
||||
* enabling collaborative route planning with full canvas editing capabilities.
|
||||
* Renders a MapLibre GL JS map with optional location presence layer.
|
||||
* Users must OPT-IN to share their location.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { MapViewport, MapLayer, Coordinate } from '../types';
|
||||
import type { PresenceView } from '../presence/types';
|
||||
import { PresenceLayer } from '../presence/PresenceLayer';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface MapCanvasProps {
|
||||
viewport: MapViewport;
|
||||
layers: MapLayer[];
|
||||
/** Initial viewport */
|
||||
viewport?: MapViewport;
|
||||
|
||||
/** Map layers to display */
|
||||
layers?: MapLayer[];
|
||||
|
||||
/** Callback when viewport changes */
|
||||
onViewportChange?: (viewport: MapViewport) => void;
|
||||
|
||||
/** Callback when map is clicked */
|
||||
onMapClick?: (coordinate: Coordinate) => void;
|
||||
onMapLoad?: () => void;
|
||||
style?: string; // MapLibre style URL
|
||||
|
||||
/** Callback when map finishes loading */
|
||||
onMapLoad?: (map: maplibregl.Map) => void;
|
||||
|
||||
/** MapLibre style URL or object */
|
||||
style?: string | maplibregl.StyleSpecification;
|
||||
|
||||
/** Whether map is interactive */
|
||||
interactive?: boolean;
|
||||
|
||||
/** Presence views to display */
|
||||
presenceViews?: PresenceView[];
|
||||
|
||||
/** Show presence uncertainty circles */
|
||||
showPresenceUncertainty?: boolean;
|
||||
|
||||
/** Callback when presence indicator is clicked */
|
||||
onPresenceClick?: (view: PresenceView) => void;
|
||||
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Default Style (OpenStreetMap tiles)
|
||||
// =============================================================================
|
||||
|
||||
const DEFAULT_STYLE: maplibregl.StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm',
|
||||
type: 'raster',
|
||||
source: 'osm',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function MapCanvas({
|
||||
viewport,
|
||||
layers,
|
||||
viewport = { center: [0, 0], zoom: 2 },
|
||||
layers = [],
|
||||
onViewportChange,
|
||||
onMapClick,
|
||||
onMapLoad,
|
||||
style = 'https://demotiles.maplibre.org/style.json',
|
||||
style = DEFAULT_STYLE,
|
||||
interactive = true,
|
||||
presenceViews = [],
|
||||
showPresenceUncertainty = true,
|
||||
onPresenceClick,
|
||||
className,
|
||||
}: MapCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [currentZoom, setCurrentZoom] = useState(viewport.zoom);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
// TODO: Initialize MapLibre GL JS instance
|
||||
// This will be implemented in Phase 1
|
||||
console.log('MapCanvas: Initializing with viewport', viewport);
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style,
|
||||
center: viewport.center as [number, number],
|
||||
zoom: viewport.zoom,
|
||||
interactive,
|
||||
attributionControl: true,
|
||||
});
|
||||
|
||||
// Add navigation controls
|
||||
if (interactive) {
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
map.addControl(new maplibregl.ScaleControl(), 'bottom-left');
|
||||
}
|
||||
|
||||
// Handle map load
|
||||
map.on('load', () => {
|
||||
setIsLoaded(true);
|
||||
onMapLoad?.(map);
|
||||
});
|
||||
|
||||
// Handle viewport changes
|
||||
map.on('moveend', () => {
|
||||
const center = map.getCenter();
|
||||
const newViewport: MapViewport = {
|
||||
center: [center.lng, center.lat],
|
||||
zoom: map.getZoom(),
|
||||
bearing: map.getBearing(),
|
||||
pitch: map.getPitch(),
|
||||
};
|
||||
setCurrentZoom(newViewport.zoom);
|
||||
onViewportChange?.(newViewport);
|
||||
});
|
||||
|
||||
// Handle zoom for presence layer
|
||||
map.on('zoom', () => {
|
||||
setCurrentZoom(map.getZoom());
|
||||
});
|
||||
|
||||
// Handle clicks
|
||||
map.on('click', (e) => {
|
||||
onMapClick?.({
|
||||
latitude: e.lngLat.lat,
|
||||
longitude: e.lngLat.lng,
|
||||
});
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
// Cleanup map instance
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update viewport externally
|
||||
useEffect(() => {
|
||||
// TODO: Update layers when they change
|
||||
console.log('MapCanvas: Updating layers', layers);
|
||||
}, [layers]);
|
||||
if (!mapRef.current || !isLoaded) return;
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Sync viewport changes
|
||||
if (isLoaded) {
|
||||
console.log('MapCanvas: Viewport changed', viewport);
|
||||
const map = mapRef.current;
|
||||
const currentCenter = map.getCenter();
|
||||
const currentZoom = map.getZoom();
|
||||
|
||||
// Only update if significantly different
|
||||
const [lng, lat] = viewport.center;
|
||||
if (
|
||||
Math.abs(currentCenter.lng - lng) > 0.0001 ||
|
||||
Math.abs(currentCenter.lat - lat) > 0.0001 ||
|
||||
Math.abs(currentZoom - viewport.zoom) > 0.1
|
||||
) {
|
||||
map.flyTo({
|
||||
center: viewport.center as [number, number],
|
||||
zoom: viewport.zoom,
|
||||
bearing: viewport.bearing ?? 0,
|
||||
pitch: viewport.pitch ?? 0,
|
||||
});
|
||||
}
|
||||
}, [viewport, isLoaded]);
|
||||
|
||||
// Update layers
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !isLoaded) return;
|
||||
// TODO: Add layer management
|
||||
console.log('MapCanvas: Updating layers', layers);
|
||||
}, [layers, isLoaded]);
|
||||
|
||||
// Project function for presence layer
|
||||
const project = useCallback((lat: number, lng: number) => {
|
||||
if (!mapRef.current) return { x: 0, y: 0 };
|
||||
const point = mapRef.current.project([lng, lat]);
|
||||
return { x: point.x, y: point.y };
|
||||
}, []);
|
||||
|
||||
// Handle presence click
|
||||
const handlePresenceClick = useCallback((indicator: any) => {
|
||||
const view = presenceViews.find((v) => v.user.pubKey === indicator.id);
|
||||
if (view && onPresenceClick) {
|
||||
onPresenceClick(view);
|
||||
}
|
||||
}, [presenceViews, onPresenceClick]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="open-mapping-canvas"
|
||||
className={`open-mapping-canvas ${className ?? ''}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Loading indicator */}
|
||||
{!isLoaded && (
|
||||
<div className="open-mapping-loading">
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
Loading map...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presence layer overlay */}
|
||||
{isLoaded && presenceViews.length > 0 && (
|
||||
<PresenceLayer
|
||||
views={presenceViews}
|
||||
project={project}
|
||||
zoom={currentZoom}
|
||||
showUncertainty={showPresenceUncertainty}
|
||||
showDirection={true}
|
||||
showNames={true}
|
||||
onIndicatorClick={handlePresenceClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,638 @@
|
|||
/**
|
||||
* Conic Geometry
|
||||
*
|
||||
* Mathematical operations for cones, conic sections, and their intersections
|
||||
* in n-dimensional possibility space.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SpacePoint,
|
||||
SpaceVector,
|
||||
PossibilityCone,
|
||||
ConicSection,
|
||||
ConicSectionType,
|
||||
ConstraintSurface,
|
||||
HyperplaneSurface,
|
||||
SphereSurface,
|
||||
ConeSurface,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Vector Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a zero vector of given dimension
|
||||
*/
|
||||
export function zeroVector(dim: number): SpaceVector {
|
||||
return { components: new Array(dim).fill(0) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unit vector along a given axis
|
||||
*/
|
||||
export function unitVector(dim: number, axis: number): SpaceVector {
|
||||
const components = new Array(dim).fill(0);
|
||||
components[axis] = 1;
|
||||
return { components };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add two vectors
|
||||
*/
|
||||
export function addVectors(a: SpaceVector, b: SpaceVector): SpaceVector {
|
||||
return {
|
||||
components: a.components.map((v, i) => v + (b.components[i] ?? 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract vectors (a - b)
|
||||
*/
|
||||
export function subtractVectors(a: SpaceVector, b: SpaceVector): SpaceVector {
|
||||
return {
|
||||
components: a.components.map((v, i) => v - (b.components[i] ?? 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale a vector
|
||||
*/
|
||||
export function scaleVector(v: SpaceVector, scalar: number): SpaceVector {
|
||||
return {
|
||||
components: v.components.map((c) => c * scalar),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dot product of two vectors
|
||||
*/
|
||||
export function dotProduct(a: SpaceVector, b: SpaceVector): number {
|
||||
return a.components.reduce(
|
||||
(sum, v, i) => sum + v * (b.components[i] ?? 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector magnitude (L2 norm)
|
||||
*/
|
||||
export function magnitude(v: SpaceVector): number {
|
||||
return Math.sqrt(dotProduct(v, v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a vector to unit length
|
||||
*/
|
||||
export function normalize(v: SpaceVector): SpaceVector {
|
||||
const mag = magnitude(v);
|
||||
if (mag === 0) return v;
|
||||
return scaleVector(v, 1 / mag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross product (3D only)
|
||||
*/
|
||||
export function crossProduct(a: SpaceVector, b: SpaceVector): SpaceVector {
|
||||
if (a.components.length !== 3 || b.components.length !== 3) {
|
||||
throw new Error('Cross product only defined for 3D vectors');
|
||||
}
|
||||
return {
|
||||
components: [
|
||||
a.components[1] * b.components[2] - a.components[2] * b.components[1],
|
||||
a.components[2] * b.components[0] - a.components[0] * b.components[2],
|
||||
a.components[0] * b.components[1] - a.components[1] * b.components[0],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance between two points
|
||||
*/
|
||||
export function distance(a: SpacePoint, b: SpacePoint): number {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < a.coordinates.length; i++) {
|
||||
const diff = a.coordinates[i] - (b.coordinates[i] ?? 0);
|
||||
sum += diff * diff;
|
||||
}
|
||||
return Math.sqrt(sum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert point to vector (from origin)
|
||||
*/
|
||||
export function pointToVector(p: SpacePoint): SpaceVector {
|
||||
return { components: [...p.coordinates] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert vector to point
|
||||
*/
|
||||
export function vectorToPoint(v: SpaceVector): SpacePoint {
|
||||
return { coordinates: [...v.components] };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cone Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a possibility cone
|
||||
*/
|
||||
export function createCone(params: {
|
||||
apex: SpacePoint;
|
||||
axis: SpaceVector;
|
||||
aperture: number;
|
||||
direction?: 'forward' | 'backward' | 'bidirectional';
|
||||
extent?: number | null;
|
||||
constraints?: string[];
|
||||
}): PossibilityCone {
|
||||
return {
|
||||
id: `cone-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
apex: params.apex,
|
||||
axis: normalize(params.axis),
|
||||
aperture: Math.max(0, Math.min(Math.PI / 2, params.aperture)),
|
||||
direction: params.direction ?? 'forward',
|
||||
extent: params.extent ?? null,
|
||||
constraints: params.constraints ?? [],
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point is inside a cone
|
||||
*/
|
||||
export function isPointInCone(point: SpacePoint, cone: PossibilityCone): boolean {
|
||||
// Vector from apex to point
|
||||
const toPoint = subtractVectors(
|
||||
pointToVector(point),
|
||||
pointToVector(cone.apex)
|
||||
);
|
||||
|
||||
const distanceFromApex = magnitude(toPoint);
|
||||
|
||||
// Check extent
|
||||
if (cone.extent !== null) {
|
||||
const axialDistance = dotProduct(toPoint, cone.axis);
|
||||
if (cone.direction === 'forward' && (axialDistance < 0 || axialDistance > cone.extent)) {
|
||||
return false;
|
||||
}
|
||||
if (cone.direction === 'backward' && (axialDistance > 0 || axialDistance < -cone.extent)) {
|
||||
return false;
|
||||
}
|
||||
if (cone.direction === 'bidirectional' && Math.abs(axialDistance) > cone.extent) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check direction
|
||||
const axialComponent = dotProduct(toPoint, cone.axis);
|
||||
if (cone.direction === 'forward' && axialComponent < 0) return false;
|
||||
if (cone.direction === 'backward' && axialComponent > 0) return false;
|
||||
|
||||
// Check angle from axis
|
||||
if (distanceFromApex === 0) return true; // At apex
|
||||
|
||||
const cosAngle = Math.abs(axialComponent) / distanceFromApex;
|
||||
const angle = Math.acos(Math.min(1, cosAngle));
|
||||
|
||||
return angle <= cone.aperture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distance from point to cone surface (signed: negative = inside)
|
||||
*/
|
||||
export function signedDistanceToCone(
|
||||
point: SpacePoint,
|
||||
cone: PossibilityCone
|
||||
): number {
|
||||
const toPoint = subtractVectors(
|
||||
pointToVector(point),
|
||||
pointToVector(cone.apex)
|
||||
);
|
||||
|
||||
const distanceFromApex = magnitude(toPoint);
|
||||
if (distanceFromApex === 0) return 0; // At apex
|
||||
|
||||
const axialComponent = dotProduct(toPoint, cone.axis);
|
||||
|
||||
// For bidirectional, use absolute value
|
||||
const effectiveAxial =
|
||||
cone.direction === 'bidirectional' ? Math.abs(axialComponent) : axialComponent;
|
||||
|
||||
// Check direction
|
||||
if (cone.direction === 'forward' && axialComponent < 0) {
|
||||
return distanceFromApex; // Behind cone
|
||||
}
|
||||
if (cone.direction === 'backward' && axialComponent > 0) {
|
||||
return distanceFromApex; // In front of backward cone
|
||||
}
|
||||
|
||||
// Angle from axis
|
||||
const cosAngle = effectiveAxial / distanceFromApex;
|
||||
const angle = Math.acos(Math.min(1, Math.max(-1, cosAngle)));
|
||||
|
||||
// Distance perpendicular to cone surface
|
||||
const angleDiff = angle - cone.aperture;
|
||||
|
||||
// Convert angular difference to linear distance (approximate)
|
||||
return angleDiff * distanceFromApex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a cone by applying a constraint (reduce aperture)
|
||||
*/
|
||||
export function narrowCone(
|
||||
cone: PossibilityCone,
|
||||
factor: number,
|
||||
constraintId: string
|
||||
): PossibilityCone {
|
||||
const newAperture = cone.aperture * Math.max(0, Math.min(1, factor));
|
||||
|
||||
return {
|
||||
...cone,
|
||||
id: `cone-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
aperture: newAperture,
|
||||
constraints: [...cone.constraints, constraintId],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift cone apex along axis
|
||||
*/
|
||||
export function shiftConeApex(
|
||||
cone: PossibilityCone,
|
||||
distance: number
|
||||
): PossibilityCone {
|
||||
const shift = scaleVector(cone.axis, distance);
|
||||
const newApex: SpacePoint = {
|
||||
coordinates: cone.apex.coordinates.map(
|
||||
(c, i) => c + (shift.components[i] ?? 0)
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
...cone,
|
||||
apex: newApex,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Conic Sections
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Determine the type of conic section from cutting plane angle
|
||||
*
|
||||
* @param coneAperture Half-angle of the cone
|
||||
* @param planeAngle Angle of cutting plane from axis (0 = perpendicular)
|
||||
*/
|
||||
export function getConicSectionType(
|
||||
coneAperture: number,
|
||||
planeAngle: number
|
||||
): ConicSectionType {
|
||||
const normalizedPlane = Math.abs(planeAngle);
|
||||
|
||||
if (normalizedPlane < 0.001) {
|
||||
return 'circle'; // Perpendicular to axis
|
||||
}
|
||||
|
||||
if (Math.abs(normalizedPlane - coneAperture) < 0.001) {
|
||||
return 'parabola'; // Parallel to cone edge
|
||||
}
|
||||
|
||||
if (normalizedPlane < coneAperture) {
|
||||
return 'ellipse'; // Steeper than cone edge, doesn't cross apex
|
||||
}
|
||||
|
||||
return 'hyperbola'; // Shallower than cone edge, crosses both nappes
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a conic section from cone and cutting plane
|
||||
*/
|
||||
export function createConicSection(
|
||||
cone: PossibilityCone,
|
||||
planeNormal: SpaceVector,
|
||||
planeOffset: number
|
||||
): ConicSection {
|
||||
// Angle between plane normal and cone axis
|
||||
const cosPlaneAngle = Math.abs(dotProduct(normalize(planeNormal), cone.axis));
|
||||
const planeAngle = Math.acos(Math.min(1, cosPlaneAngle));
|
||||
|
||||
const type = getConicSectionType(cone.aperture, planeAngle);
|
||||
|
||||
// Calculate eccentricity
|
||||
let eccentricity: number;
|
||||
if (type === 'circle') {
|
||||
eccentricity = 0;
|
||||
} else if (type === 'parabola') {
|
||||
eccentricity = 1;
|
||||
} else if (type === 'ellipse') {
|
||||
eccentricity = Math.sin(planeAngle) / Math.sin(cone.aperture);
|
||||
} else {
|
||||
eccentricity = Math.sin(planeAngle) / Math.sin(cone.aperture);
|
||||
}
|
||||
|
||||
// Calculate semi-axes (simplified for 3D case)
|
||||
const d = planeOffset / dotProduct(planeNormal, cone.axis);
|
||||
const r = Math.abs(d) * Math.tan(cone.aperture);
|
||||
|
||||
let a: number | undefined;
|
||||
let b: number | undefined;
|
||||
let p: number | undefined;
|
||||
|
||||
if (type === 'circle') {
|
||||
a = r;
|
||||
b = r;
|
||||
} else if (type === 'ellipse' || type === 'hyperbola') {
|
||||
a = r / (1 - eccentricity * eccentricity);
|
||||
b = a * Math.sqrt(Math.abs(1 - eccentricity * eccentricity));
|
||||
} else if (type === 'parabola') {
|
||||
p = 2 * r;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
center: { x: 0, y: 0 }, // Would need proper calculation
|
||||
a,
|
||||
b,
|
||||
p,
|
||||
rotation: 0,
|
||||
eccentricity,
|
||||
slicePlane: {
|
||||
normal: planeNormal,
|
||||
offset: planeOffset,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constraint Surfaces
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate signed distance from point to constraint surface
|
||||
*/
|
||||
export function signedDistanceToSurface(
|
||||
point: SpacePoint,
|
||||
surface: ConstraintSurface
|
||||
): number {
|
||||
switch (surface.type) {
|
||||
case 'hyperplane':
|
||||
return signedDistanceToHyperplane(point, surface);
|
||||
case 'sphere':
|
||||
return signedDistanceToSphere(point, surface);
|
||||
case 'cone':
|
||||
return signedDistanceToCone(point, surface.cone) *
|
||||
(surface.validRegion === 'inside' ? 1 : -1);
|
||||
case 'custom':
|
||||
// Would need to evaluate custom function
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function signedDistanceToHyperplane(
|
||||
point: SpacePoint,
|
||||
plane: HyperplaneSurface
|
||||
): number {
|
||||
const pv = pointToVector(point);
|
||||
const dist = dotProduct(pv, plane.normal) - plane.offset;
|
||||
return plane.validSide === 'positive' ? -dist : dist;
|
||||
}
|
||||
|
||||
function signedDistanceToSphere(
|
||||
point: SpacePoint,
|
||||
sphere: SphereSurface
|
||||
): number {
|
||||
const dist = distance(point, sphere.center) - sphere.radius;
|
||||
return sphere.validRegion === 'inside' ? dist : -dist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point satisfies a constraint
|
||||
*/
|
||||
export function satisfiesConstraint(
|
||||
point: SpacePoint,
|
||||
surface: ConstraintSurface
|
||||
): boolean {
|
||||
return signedDistanceToSurface(point, surface) <= 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cone Intersections
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if a point is in the intersection of multiple cones
|
||||
*/
|
||||
export function isPointInIntersection(
|
||||
point: SpacePoint,
|
||||
cones: PossibilityCone[]
|
||||
): boolean {
|
||||
return cones.every((cone) => isPointInCone(point, cone));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate intersection volume using Monte Carlo sampling
|
||||
*/
|
||||
export function estimateIntersectionVolume(
|
||||
cones: PossibilityCone[],
|
||||
bounds: { min: SpacePoint; max: SpacePoint },
|
||||
samples: number = 10000
|
||||
): number {
|
||||
if (cones.length === 0) return 1;
|
||||
|
||||
let insideCount = 0;
|
||||
const dim = bounds.min.coordinates.length;
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
// Random point in bounding box
|
||||
const point: SpacePoint = {
|
||||
coordinates: bounds.min.coordinates.map(
|
||||
(min, j) => min + Math.random() * (bounds.max.coordinates[j] - min)
|
||||
),
|
||||
};
|
||||
|
||||
if (isPointInIntersection(point, cones)) {
|
||||
insideCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Volume of bounding box
|
||||
let boxVolume = 1;
|
||||
for (let i = 0; i < dim; i++) {
|
||||
boxVolume *= bounds.max.coordinates[i] - bounds.min.coordinates[i];
|
||||
}
|
||||
|
||||
return (insideCount / samples) * boxVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the "waist" of a cone intersection (narrowest cross-section)
|
||||
* by sampling along the primary axis
|
||||
*/
|
||||
export function findIntersectionWaist(
|
||||
cones: PossibilityCone[],
|
||||
axisIndex: number,
|
||||
bounds: { min: SpacePoint; max: SpacePoint },
|
||||
resolution: number = 50
|
||||
): { position: SpacePoint; area: number } | null {
|
||||
if (cones.length === 0) return null;
|
||||
|
||||
const dim = bounds.min.coordinates.length;
|
||||
const axisMin = bounds.min.coordinates[axisIndex];
|
||||
const axisMax = bounds.max.coordinates[axisIndex];
|
||||
const step = (axisMax - axisMin) / resolution;
|
||||
|
||||
let minArea = Infinity;
|
||||
let minPosition: SpacePoint | null = null;
|
||||
|
||||
for (let i = 0; i <= resolution; i++) {
|
||||
const axisValue = axisMin + i * step;
|
||||
|
||||
// Sample cross-section at this axis value
|
||||
let insideCount = 0;
|
||||
const crossSectionSamples = 1000;
|
||||
|
||||
for (let j = 0; j < crossSectionSamples; j++) {
|
||||
const point: SpacePoint = {
|
||||
coordinates: bounds.min.coordinates.map((min, k) => {
|
||||
if (k === axisIndex) return axisValue;
|
||||
return min + Math.random() * (bounds.max.coordinates[k] - min);
|
||||
}),
|
||||
};
|
||||
|
||||
if (isPointInIntersection(point, cones)) {
|
||||
insideCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const area = insideCount / crossSectionSamples;
|
||||
|
||||
if (area > 0 && area < minArea) {
|
||||
minArea = area;
|
||||
minPosition = {
|
||||
coordinates: bounds.min.coordinates.map((min, k) => {
|
||||
if (k === axisIndex) return axisValue;
|
||||
return (min + bounds.max.coordinates[k]) / 2;
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (minPosition === null) return null;
|
||||
|
||||
return {
|
||||
position: minPosition,
|
||||
area: minArea,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Projection
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Project a point to 2D using orthographic projection
|
||||
*/
|
||||
export function projectOrthographic(
|
||||
point: SpacePoint,
|
||||
xAxis: number,
|
||||
yAxis: number
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: point.coordinates[xAxis] ?? 0,
|
||||
y: point.coordinates[yAxis] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a point to 2D using perspective projection
|
||||
*/
|
||||
export function projectPerspective(
|
||||
point: SpacePoint,
|
||||
xAxis: number,
|
||||
yAxis: number,
|
||||
depthAxis: number,
|
||||
focalLength: number = 1
|
||||
): { x: number; y: number } {
|
||||
const depth = point.coordinates[depthAxis] ?? 1;
|
||||
const scale = focalLength / (focalLength + depth);
|
||||
|
||||
return {
|
||||
x: (point.coordinates[xAxis] ?? 0) * scale,
|
||||
y: (point.coordinates[yAxis] ?? 0) * scale,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate points on cone surface for visualization
|
||||
*/
|
||||
export function sampleConeSurface(
|
||||
cone: PossibilityCone,
|
||||
radialSamples: number = 16,
|
||||
axialSamples: number = 10
|
||||
): SpacePoint[] {
|
||||
const points: SpacePoint[] = [];
|
||||
const dim = cone.apex.coordinates.length;
|
||||
|
||||
// Find orthogonal vectors to axis
|
||||
const ortho1 = findOrthogonalVector(cone.axis);
|
||||
const ortho2 = dim >= 3 ? crossProduct(cone.axis, ortho1) : ortho1;
|
||||
|
||||
const maxExtent = cone.extent ?? 10;
|
||||
|
||||
for (let i = 0; i < axialSamples; i++) {
|
||||
const t = (i / (axialSamples - 1)) * maxExtent;
|
||||
const radius = t * Math.tan(cone.aperture);
|
||||
|
||||
for (let j = 0; j < radialSamples; j++) {
|
||||
const angle = (j / radialSamples) * Math.PI * 2;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
|
||||
const point: SpacePoint = {
|
||||
coordinates: cone.apex.coordinates.map((c, k) => {
|
||||
const axialOffset = (cone.axis.components[k] ?? 0) * t;
|
||||
const radialOffset =
|
||||
radius * cos * (ortho1.components[k] ?? 0) +
|
||||
radius * sin * (ortho2.components[k] ?? 0);
|
||||
return c + axialOffset + radialOffset;
|
||||
}),
|
||||
};
|
||||
|
||||
points.push(point);
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a vector orthogonal to the given vector
|
||||
*/
|
||||
function findOrthogonalVector(v: SpaceVector): SpaceVector {
|
||||
const dim = v.components.length;
|
||||
|
||||
// Find component with smallest magnitude
|
||||
let minIdx = 0;
|
||||
let minVal = Math.abs(v.components[0]);
|
||||
|
||||
for (let i = 1; i < dim; i++) {
|
||||
if (Math.abs(v.components[i]) < minVal) {
|
||||
minVal = Math.abs(v.components[i]);
|
||||
minIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Create vector with 1 in that position
|
||||
const other = zeroVector(dim);
|
||||
other.components[minIdx] = 1;
|
||||
|
||||
// Gram-Schmidt orthogonalization
|
||||
const projection = dotProduct(v, other);
|
||||
const result = subtractVectors(other, scaleVector(v, projection));
|
||||
|
||||
return normalize(result);
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* Possibility Cones and Constraint Propagation
|
||||
*
|
||||
* A mathematical framework for visualizing how constraints propagate
|
||||
* through decision pipelines. Each decision point creates a "possibility
|
||||
* cone" - a light-cone-like structure representing reachable futures.
|
||||
* Subsequent constraints act as apertures that narrow these cones.
|
||||
*
|
||||
* The intersection of overlapping cones from multiple constraints
|
||||
* defines the valid solution manifold, through which we can find
|
||||
* value-weighted optimal paths.
|
||||
*
|
||||
* Key Concepts:
|
||||
* - Forward cones: Future possibilities from a decision point
|
||||
* - Backward cones: Past decisions that could lead to a state
|
||||
* - Apertures: Constraint surfaces that narrow cones
|
||||
* - Waist: The narrowest point where cones meet (bottleneck)
|
||||
* - Caustics: Where many cone edges converge (critical points)
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import {
|
||||
* createPipelineManager,
|
||||
* createConstraint,
|
||||
* PathOptimizer,
|
||||
* } from './conics';
|
||||
*
|
||||
* // Create pipeline manager
|
||||
* const manager = createPipelineManager({
|
||||
* dimensions: 4,
|
||||
* dimensionLabels: ['Time', 'Value', 'Risk', 'Resources'],
|
||||
* });
|
||||
*
|
||||
* // Create a pipeline from an origin point
|
||||
* const pipeline = manager.createPipeline('Planning', {
|
||||
* coordinates: [0, 50, 50, 100],
|
||||
* });
|
||||
*
|
||||
* // Add constraint stages
|
||||
* manager.addStage(pipeline.id, 'Deadlines', [
|
||||
* createConstraint({
|
||||
* label: 'Q1 Deadline',
|
||||
* type: 'temporal',
|
||||
* restrictiveness: 0.3,
|
||||
* }),
|
||||
* ]);
|
||||
*
|
||||
* manager.addStage(pipeline.id, 'Budget', [
|
||||
* createConstraint({
|
||||
* label: 'Budget Cap',
|
||||
* type: 'resource',
|
||||
* restrictiveness: 0.4,
|
||||
* }),
|
||||
* ]);
|
||||
*
|
||||
* // Run pipeline and compute intersection
|
||||
* const intersection = manager.runPipeline(pipeline.id);
|
||||
*
|
||||
* // Find optimal path through constrained space
|
||||
* const optimizer = new PathOptimizer(manager, pipeline.id, {
|
||||
* algorithm: 'a-star',
|
||||
* });
|
||||
*
|
||||
* const result = optimizer.findOptimalPath(
|
||||
* { coordinates: [0, 50, 50, 100] },
|
||||
* { coordinates: [100, 80, 20, 50] }
|
||||
* );
|
||||
*
|
||||
* console.log('Best path:', result.bestPath);
|
||||
* console.log('Optimality:', result.bestPath.optimalityScore);
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Core types
|
||||
export type {
|
||||
// Dimensional space
|
||||
SpacePoint,
|
||||
SpaceVector,
|
||||
|
||||
// Cone primitives
|
||||
ConeDirection,
|
||||
PossibilityCone,
|
||||
|
||||
// Constraints
|
||||
ConeConstraint,
|
||||
ConstraintType,
|
||||
ConstraintSurface,
|
||||
HyperplaneSurface,
|
||||
SphereSurface,
|
||||
ConeSurface,
|
||||
CustomSurface,
|
||||
|
||||
// Conic sections
|
||||
ConicSectionType,
|
||||
ConicSection,
|
||||
|
||||
// Intersections
|
||||
ConeIntersection,
|
||||
IntersectionBoundary,
|
||||
ValueField,
|
||||
|
||||
// Pipeline
|
||||
ConstraintPipeline,
|
||||
PipelineStage,
|
||||
|
||||
// Paths
|
||||
PossibilityPath,
|
||||
PathWaypoint,
|
||||
|
||||
// Optimization
|
||||
OptimizationConfig,
|
||||
OptimizationResult,
|
||||
|
||||
// Visualization
|
||||
ProjectionMode,
|
||||
ConicVisualization,
|
||||
|
||||
// Events
|
||||
ConicEvent,
|
||||
ConicEventListener,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
DIMENSION,
|
||||
DEFAULT_OPTIMIZATION_CONFIG,
|
||||
DEFAULT_CONIC_VISUALIZATION,
|
||||
} from './types';
|
||||
|
||||
// Geometry functions
|
||||
export {
|
||||
// Vector operations
|
||||
vectorAdd,
|
||||
vectorSubtract,
|
||||
vectorScale,
|
||||
vectorDot,
|
||||
vectorNorm,
|
||||
vectorNormalize,
|
||||
vectorCross3D,
|
||||
|
||||
// Cone operations
|
||||
createCone,
|
||||
isPointInCone,
|
||||
signedDistanceToCone,
|
||||
angleFromAxis,
|
||||
narrowCone,
|
||||
combineCones,
|
||||
|
||||
// Conic sections
|
||||
getConicSectionType,
|
||||
sliceConeWithPlane,
|
||||
|
||||
// Constraint surfaces
|
||||
signedDistanceToSurface,
|
||||
|
||||
// Intersection operations
|
||||
estimateIntersectionVolume,
|
||||
findIntersectionWaist,
|
||||
} from './geometry';
|
||||
|
||||
// Pipeline management
|
||||
export {
|
||||
ConstraintPipelineManager,
|
||||
createPipelineManager,
|
||||
analyzeConstraintDependencies,
|
||||
createConstraint,
|
||||
DEFAULT_PIPELINE_CONFIG,
|
||||
type PipelineConfig,
|
||||
} from './pipeline';
|
||||
|
||||
// Path optimization
|
||||
export {
|
||||
PathOptimizer,
|
||||
createPathOptimizer,
|
||||
DEFAULT_OPTIMIZER_CONFIG,
|
||||
type OptimizerConfig,
|
||||
} from './optimization';
|
||||
|
||||
// Visualization
|
||||
export {
|
||||
// Color utilities
|
||||
interpolateColor,
|
||||
withAlpha,
|
||||
|
||||
// Projection
|
||||
projectPoint,
|
||||
type ProjectionViewParams,
|
||||
|
||||
// Cone rendering
|
||||
generateConeEdgePoints,
|
||||
generateConeFillPath,
|
||||
|
||||
// Conic sections
|
||||
generateConicSectionPoints,
|
||||
|
||||
// Path visualization
|
||||
generatePathVisualization,
|
||||
type PathVisualization,
|
||||
|
||||
// Pipeline visualization
|
||||
generatePipelineVisualization,
|
||||
type PipelineVisualization,
|
||||
type StageVisual,
|
||||
|
||||
// Constraint surfaces
|
||||
generateConstraintSurfacePoints,
|
||||
|
||||
// Intersection visualization
|
||||
generateIntersectionVisualization,
|
||||
type IntersectionVisualization,
|
||||
|
||||
// Heat maps
|
||||
generateValueHeatMap,
|
||||
type HeatMapData,
|
||||
|
||||
// SVG generation
|
||||
pointsToSvgPath,
|
||||
conicSectionToSvgPath,
|
||||
|
||||
// Canvas rendering
|
||||
renderConeToCanvas,
|
||||
renderPathToCanvas,
|
||||
renderIntersectionToCanvas,
|
||||
|
||||
// Animation
|
||||
generateNarrowingAnimation,
|
||||
generateWaistPulse,
|
||||
type ConeAnimationFrame,
|
||||
|
||||
// Caustic detection
|
||||
findCausticPoints,
|
||||
} from './visualization';
|
||||
|
|
@ -0,0 +1,747 @@
|
|||
/**
|
||||
* Value-Weighted Path Optimization
|
||||
*
|
||||
* Find optimal paths through the intersection of possibility cones,
|
||||
* maximizing accumulated value while satisfying constraints.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SpacePoint,
|
||||
SpaceVector,
|
||||
PossibilityCone,
|
||||
PossibilityPath,
|
||||
PathWaypoint,
|
||||
OptimizationConfig,
|
||||
OptimizationResult,
|
||||
ConeConstraint,
|
||||
ValueField,
|
||||
} from './types';
|
||||
import { DEFAULT_OPTIMIZATION_CONFIG } from './types';
|
||||
import {
|
||||
distance,
|
||||
addVectors,
|
||||
subtractVectors,
|
||||
scaleVector,
|
||||
normalize,
|
||||
magnitude,
|
||||
pointToVector,
|
||||
vectorToPoint,
|
||||
isPointInCone,
|
||||
isPointInIntersection,
|
||||
signedDistanceToSurface,
|
||||
} from './geometry';
|
||||
|
||||
// =============================================================================
|
||||
// Path Optimizer
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Path optimization engine
|
||||
*/
|
||||
export class PathOptimizer {
|
||||
private config: OptimizationConfig;
|
||||
private cones: PossibilityCone[] = [];
|
||||
private constraints: ConeConstraint[] = [];
|
||||
private valueField?: ValueField;
|
||||
private bounds: { min: SpacePoint; max: SpacePoint };
|
||||
|
||||
constructor(
|
||||
bounds: { min: SpacePoint; max: SpacePoint },
|
||||
config: Partial<OptimizationConfig> = {}
|
||||
) {
|
||||
this.config = { ...DEFAULT_OPTIMIZATION_CONFIG, ...config };
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the cones to navigate through
|
||||
*/
|
||||
setCones(cones: PossibilityCone[]): void {
|
||||
this.cones = cones;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set additional constraints
|
||||
*/
|
||||
setConstraints(constraints: ConeConstraint[]): void {
|
||||
this.constraints = constraints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value field for optimization
|
||||
*/
|
||||
setValueField(field: ValueField): void {
|
||||
this.valueField = field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find optimal path from start to goal
|
||||
*/
|
||||
findOptimalPath(
|
||||
start: SpacePoint,
|
||||
goal: SpacePoint
|
||||
): OptimizationResult {
|
||||
const startTime = Date.now();
|
||||
|
||||
switch (this.config.algorithm) {
|
||||
case 'a-star':
|
||||
return this.aStarSearch(start, goal, startTime);
|
||||
case 'dijkstra':
|
||||
return this.dijkstraSearch(start, goal, startTime);
|
||||
case 'gradient-descent':
|
||||
return this.gradientDescent(start, goal, startTime);
|
||||
case 'simulated-annealing':
|
||||
return this.simulatedAnnealing(start, goal, startTime);
|
||||
default:
|
||||
return this.aStarSearch(start, goal, startTime);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// A* Search
|
||||
// ===========================================================================
|
||||
|
||||
private aStarSearch(
|
||||
start: SpacePoint,
|
||||
goal: SpacePoint,
|
||||
startTime: number
|
||||
): OptimizationResult {
|
||||
const dim = start.coordinates.length;
|
||||
const resolution = this.config.samplingResolution;
|
||||
|
||||
// Discretize space
|
||||
const grid = this.createGrid(resolution);
|
||||
|
||||
// Find grid cells for start and goal
|
||||
const startCell = this.pointToCell(start, resolution);
|
||||
const goalCell = this.pointToCell(goal, resolution);
|
||||
|
||||
// Priority queue (min-heap by f-score)
|
||||
const openSet: Array<{ cell: number[]; fScore: number }> = [
|
||||
{ cell: startCell, fScore: 0 },
|
||||
];
|
||||
|
||||
// Track visited cells and their g-scores
|
||||
const gScore = new Map<string, number>();
|
||||
const fScore = new Map<string, number>();
|
||||
const cameFrom = new Map<string, number[]>();
|
||||
|
||||
const cellKey = (cell: number[]) => cell.join(',');
|
||||
gScore.set(cellKey(startCell), 0);
|
||||
fScore.set(cellKey(startCell), this.heuristic(startCell, goalCell, resolution));
|
||||
|
||||
let iterations = 0;
|
||||
|
||||
while (openSet.length > 0 && iterations < this.config.maxIterations) {
|
||||
iterations++;
|
||||
|
||||
// Get cell with lowest f-score
|
||||
openSet.sort((a, b) => a.fScore - b.fScore);
|
||||
const current = openSet.shift()!;
|
||||
const currentKey = cellKey(current.cell);
|
||||
|
||||
// Check if reached goal
|
||||
if (this.cellsEqual(current.cell, goalCell)) {
|
||||
const path = this.reconstructPath(cameFrom, current.cell, start, goal, resolution);
|
||||
return this.createResult(path, iterations, true, startTime);
|
||||
}
|
||||
|
||||
// Explore neighbors
|
||||
const neighbors = this.getNeighborCells(current.cell, resolution);
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
const neighborKey = cellKey(neighbor);
|
||||
const neighborPoint = this.cellToPoint(neighbor, resolution);
|
||||
|
||||
// Check if valid (in cone intersection)
|
||||
if (!this.isValidPoint(neighborPoint)) continue;
|
||||
|
||||
// Calculate tentative g-score
|
||||
const currentPoint = this.cellToPoint(current.cell, resolution);
|
||||
const moveCost = this.getMoveCost(currentPoint, neighborPoint);
|
||||
const tentativeG = (gScore.get(currentKey) ?? Infinity) + moveCost;
|
||||
|
||||
if (tentativeG < (gScore.get(neighborKey) ?? Infinity)) {
|
||||
// Better path found
|
||||
cameFrom.set(neighborKey, current.cell);
|
||||
gScore.set(neighborKey, tentativeG);
|
||||
const h = this.heuristic(neighbor, goalCell, resolution);
|
||||
const f = tentativeG + h;
|
||||
fScore.set(neighborKey, f);
|
||||
|
||||
if (!openSet.some((n) => cellKey(n.cell) === neighborKey)) {
|
||||
openSet.push({ cell: neighbor, fScore: f });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No path found - return best effort
|
||||
const partialPath = this.createDirectPath(start, goal);
|
||||
return this.createResult(partialPath, iterations, false, startTime);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Dijkstra Search
|
||||
// ===========================================================================
|
||||
|
||||
private dijkstraSearch(
|
||||
start: SpacePoint,
|
||||
goal: SpacePoint,
|
||||
startTime: number
|
||||
): OptimizationResult {
|
||||
// Simplified Dijkstra (A* with h=0)
|
||||
const originalConfig = this.config;
|
||||
this.config = { ...originalConfig };
|
||||
|
||||
const result = this.aStarSearch(start, goal, startTime);
|
||||
|
||||
this.config = originalConfig;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Gradient Descent
|
||||
// ===========================================================================
|
||||
|
||||
private gradientDescent(
|
||||
start: SpacePoint,
|
||||
goal: SpacePoint,
|
||||
startTime: number
|
||||
): OptimizationResult {
|
||||
const dim = start.coordinates.length;
|
||||
const stepSize = 0.1;
|
||||
const waypoints: PathWaypoint[] = [];
|
||||
|
||||
let current = { ...start, coordinates: [...start.coordinates] };
|
||||
let iterations = 0;
|
||||
let totalValue = this.getValueAt(current);
|
||||
let distanceToGoal = distance(current, goal);
|
||||
|
||||
while (iterations < this.config.maxIterations && distanceToGoal > 0.1) {
|
||||
iterations++;
|
||||
|
||||
// Calculate gradient (toward goal + value gradient)
|
||||
const toGoal = subtractVectors(pointToVector(goal), pointToVector(current));
|
||||
const goalDir = normalize(toGoal);
|
||||
|
||||
// Value gradient (finite differences)
|
||||
const valueGrad = this.estimateValueGradient(current);
|
||||
|
||||
// Combined gradient
|
||||
const combinedGrad = addVectors(
|
||||
scaleVector(goalDir, this.config.weights.length),
|
||||
scaleVector(valueGrad, this.config.weights.value)
|
||||
);
|
||||
|
||||
const step = scaleVector(normalize(combinedGrad), stepSize);
|
||||
|
||||
// Proposed new position
|
||||
const proposed: SpacePoint = {
|
||||
coordinates: current.coordinates.map(
|
||||
(c, i) => c + (step.components[i] ?? 0)
|
||||
),
|
||||
};
|
||||
|
||||
// Check validity
|
||||
if (this.isValidPoint(proposed)) {
|
||||
// Add waypoint
|
||||
waypoints.push({
|
||||
position: current,
|
||||
value: this.getValueAt(current),
|
||||
distanceFromStart: waypoints.length > 0
|
||||
? waypoints[waypoints.length - 1].distanceFromStart + distance(current, proposed)
|
||||
: 0,
|
||||
containingCones: this.getContainingCones(current),
|
||||
});
|
||||
|
||||
current = proposed;
|
||||
totalValue += this.getValueAt(current);
|
||||
distanceToGoal = distance(current, goal);
|
||||
} else {
|
||||
// Try to project back into valid region
|
||||
const projected = this.projectToValidRegion(proposed);
|
||||
if (projected) {
|
||||
current = projected;
|
||||
} else {
|
||||
break; // Stuck
|
||||
}
|
||||
}
|
||||
|
||||
// Check convergence
|
||||
if (distanceToGoal < this.config.convergenceThreshold) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add final waypoint
|
||||
waypoints.push({
|
||||
position: current,
|
||||
value: this.getValueAt(current),
|
||||
distanceFromStart: waypoints.length > 0
|
||||
? waypoints[waypoints.length - 1].distanceFromStart + distance(current, goal)
|
||||
: distance(start, goal),
|
||||
containingCones: this.getContainingCones(current),
|
||||
});
|
||||
|
||||
const path = this.waypointsToPath(waypoints);
|
||||
return this.createResult(path, iterations, distanceToGoal < 0.1, startTime);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Simulated Annealing
|
||||
// ===========================================================================
|
||||
|
||||
private simulatedAnnealing(
|
||||
start: SpacePoint,
|
||||
goal: SpacePoint,
|
||||
startTime: number
|
||||
): OptimizationResult {
|
||||
const dim = start.coordinates.length;
|
||||
|
||||
// Initialize with direct path
|
||||
let currentPath = this.createDirectPath(start, goal);
|
||||
let currentScore = this.scorePath(currentPath);
|
||||
let bestPath = currentPath;
|
||||
let bestScore = currentScore;
|
||||
|
||||
let temperature = 1.0;
|
||||
const coolingRate = 0.995;
|
||||
let iterations = 0;
|
||||
|
||||
while (
|
||||
iterations < this.config.maxIterations &&
|
||||
temperature > this.config.convergenceThreshold
|
||||
) {
|
||||
iterations++;
|
||||
|
||||
// Generate neighbor solution (perturb random waypoint)
|
||||
const neighborPath = this.perturbPath(currentPath);
|
||||
const neighborScore = this.scorePath(neighborPath);
|
||||
|
||||
// Acceptance probability
|
||||
const delta = neighborScore - currentScore;
|
||||
const acceptProb = delta > 0 ? 1 : Math.exp(delta / temperature);
|
||||
|
||||
if (Math.random() < acceptProb) {
|
||||
currentPath = neighborPath;
|
||||
currentScore = neighborScore;
|
||||
|
||||
if (currentScore > bestScore) {
|
||||
bestPath = currentPath;
|
||||
bestScore = currentScore;
|
||||
}
|
||||
}
|
||||
|
||||
temperature *= coolingRate;
|
||||
}
|
||||
|
||||
return this.createResult(bestPath, iterations, true, startTime);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Helper Methods
|
||||
// ===========================================================================
|
||||
|
||||
private createGrid(resolution: number): void {
|
||||
// Grid is implicit - we just use resolution to map points to cells
|
||||
}
|
||||
|
||||
private pointToCell(point: SpacePoint, resolution: number): number[] {
|
||||
return point.coordinates.map((c, i) => {
|
||||
const min = this.bounds.min.coordinates[i];
|
||||
const max = this.bounds.max.coordinates[i];
|
||||
const normalized = (c - min) / (max - min);
|
||||
return Math.floor(normalized * resolution);
|
||||
});
|
||||
}
|
||||
|
||||
private cellToPoint(cell: number[], resolution: number): SpacePoint {
|
||||
return {
|
||||
coordinates: cell.map((c, i) => {
|
||||
const min = this.bounds.min.coordinates[i];
|
||||
const max = this.bounds.max.coordinates[i];
|
||||
return min + ((c + 0.5) / resolution) * (max - min);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private cellsEqual(a: number[], b: number[]): boolean {
|
||||
return a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
private getNeighborCells(cell: number[], resolution: number): number[][] {
|
||||
const neighbors: number[][] = [];
|
||||
const dim = cell.length;
|
||||
|
||||
// Generate all adjacent cells (26 neighbors in 3D, etc.)
|
||||
const directions: number[] = [-1, 0, 1];
|
||||
|
||||
const generate = (index: number, current: number[]): void => {
|
||||
if (index === dim) {
|
||||
if (!current.every((v, i) => v === cell[i])) {
|
||||
// Check bounds
|
||||
if (current.every((v, i) => v >= 0 && v < resolution)) {
|
||||
neighbors.push([...current]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const d of directions) {
|
||||
current[index] = cell[index] + d;
|
||||
generate(index + 1, current);
|
||||
}
|
||||
};
|
||||
|
||||
generate(0, new Array(dim).fill(0));
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
private heuristic(
|
||||
cell: number[],
|
||||
goalCell: number[],
|
||||
resolution: number
|
||||
): number {
|
||||
// Euclidean distance in cell space
|
||||
let sum = 0;
|
||||
for (let i = 0; i < cell.length; i++) {
|
||||
const diff = cell[i] - goalCell[i];
|
||||
sum += diff * diff;
|
||||
}
|
||||
return Math.sqrt(sum);
|
||||
}
|
||||
|
||||
private getMoveCost(from: SpacePoint, to: SpacePoint): number {
|
||||
const dist = distance(from, to);
|
||||
const value = (this.getValueAt(from) + this.getValueAt(to)) / 2;
|
||||
const risk = this.getRiskAt(to);
|
||||
|
||||
// Cost = distance - value + risk
|
||||
return (
|
||||
dist * this.config.weights.length -
|
||||
value * this.config.weights.value +
|
||||
risk * this.config.weights.risk
|
||||
);
|
||||
}
|
||||
|
||||
private isValidPoint(point: SpacePoint): boolean {
|
||||
// Check bounds
|
||||
for (let i = 0; i < point.coordinates.length; i++) {
|
||||
if (
|
||||
point.coordinates[i] < this.bounds.min.coordinates[i] ||
|
||||
point.coordinates[i] > this.bounds.max.coordinates[i]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cone intersection
|
||||
if (!isPointInIntersection(point, this.cones)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check hard constraints
|
||||
for (const constraint of this.constraints) {
|
||||
if (constraint.hardness === 'hard') {
|
||||
if (signedDistanceToSurface(point, constraint.surface) > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getValueAt(point: SpacePoint): number {
|
||||
if (!this.valueField) {
|
||||
// Default: higher value along value dimension
|
||||
return point.coordinates[1] ?? 0;
|
||||
}
|
||||
|
||||
// Trilinear interpolation from value field
|
||||
// Simplified: nearest neighbor
|
||||
const resolution = this.valueField.resolution;
|
||||
const indices = point.coordinates.map((c, i) => {
|
||||
const min = this.bounds.min.coordinates[i];
|
||||
const max = this.bounds.max.coordinates[i];
|
||||
const normalized = (c - min) / (max - min);
|
||||
return Math.floor(normalized * (resolution[i] - 1));
|
||||
});
|
||||
|
||||
// Flatten index
|
||||
let flatIndex = 0;
|
||||
let stride = 1;
|
||||
for (let i = indices.length - 1; i >= 0; i--) {
|
||||
flatIndex += indices[i] * stride;
|
||||
stride *= resolution[i];
|
||||
}
|
||||
|
||||
return this.valueField.values[flatIndex] ?? 0;
|
||||
}
|
||||
|
||||
private getRiskAt(point: SpacePoint): number {
|
||||
// Default: lower risk toward center of cones
|
||||
let totalDistance = 0;
|
||||
for (const cone of this.cones) {
|
||||
totalDistance += Math.abs(
|
||||
isPointInCone(point, cone) ? -1 : 1
|
||||
);
|
||||
}
|
||||
return totalDistance / Math.max(1, this.cones.length);
|
||||
}
|
||||
|
||||
private estimateValueGradient(point: SpacePoint): SpaceVector {
|
||||
const epsilon = 0.01;
|
||||
const dim = point.coordinates.length;
|
||||
const gradient: number[] = [];
|
||||
|
||||
for (let i = 0; i < dim; i++) {
|
||||
const forward: SpacePoint = {
|
||||
coordinates: point.coordinates.map((c, j) =>
|
||||
j === i ? c + epsilon : c
|
||||
),
|
||||
};
|
||||
const backward: SpacePoint = {
|
||||
coordinates: point.coordinates.map((c, j) =>
|
||||
j === i ? c - epsilon : c
|
||||
),
|
||||
};
|
||||
|
||||
const fValue = this.isValidPoint(forward) ? this.getValueAt(forward) : 0;
|
||||
const bValue = this.isValidPoint(backward) ? this.getValueAt(backward) : 0;
|
||||
|
||||
gradient.push((fValue - bValue) / (2 * epsilon));
|
||||
}
|
||||
|
||||
return { components: gradient };
|
||||
}
|
||||
|
||||
private projectToValidRegion(point: SpacePoint): SpacePoint | null {
|
||||
// Simple projection: move toward nearest valid point
|
||||
// This is a simplified version - real implementation would be more sophisticated
|
||||
const maxAttempts = 10;
|
||||
let current = point;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (this.isValidPoint(current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
// Move toward center of bounds
|
||||
const center: SpacePoint = {
|
||||
coordinates: this.bounds.min.coordinates.map(
|
||||
(min, j) => (min + this.bounds.max.coordinates[j]) / 2
|
||||
),
|
||||
};
|
||||
|
||||
const toCenter = subtractVectors(
|
||||
pointToVector(center),
|
||||
pointToVector(current)
|
||||
);
|
||||
const step = scaleVector(normalize(toCenter), 0.1);
|
||||
|
||||
current = {
|
||||
coordinates: current.coordinates.map(
|
||||
(c, j) => c + (step.components[j] ?? 0)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getContainingCones(point: SpacePoint): string[] {
|
||||
return this.cones
|
||||
.filter((cone) => isPointInCone(point, cone))
|
||||
.map((cone) => cone.id);
|
||||
}
|
||||
|
||||
private reconstructPath(
|
||||
cameFrom: Map<string, number[]>,
|
||||
goalCell: number[],
|
||||
start: SpacePoint,
|
||||
goal: SpacePoint,
|
||||
resolution: number
|
||||
): PossibilityPath {
|
||||
const waypoints: PathWaypoint[] = [];
|
||||
let current = goalCell;
|
||||
const cellKey = (cell: number[]) => cell.join(',');
|
||||
|
||||
// Trace back
|
||||
const cells: number[][] = [current];
|
||||
while (cameFrom.has(cellKey(current))) {
|
||||
current = cameFrom.get(cellKey(current))!;
|
||||
cells.unshift(current);
|
||||
}
|
||||
|
||||
// Convert to waypoints
|
||||
let distanceFromStart = 0;
|
||||
let prevPoint = start;
|
||||
|
||||
for (const cell of cells) {
|
||||
const point = this.cellToPoint(cell, resolution);
|
||||
distanceFromStart += distance(prevPoint, point);
|
||||
|
||||
waypoints.push({
|
||||
position: point,
|
||||
value: this.getValueAt(point),
|
||||
distanceFromStart,
|
||||
containingCones: this.getContainingCones(point),
|
||||
});
|
||||
|
||||
prevPoint = point;
|
||||
}
|
||||
|
||||
return this.waypointsToPath(waypoints);
|
||||
}
|
||||
|
||||
private createDirectPath(start: SpacePoint, goal: SpacePoint): PossibilityPath {
|
||||
const waypoints: PathWaypoint[] = [
|
||||
{
|
||||
position: start,
|
||||
value: this.getValueAt(start),
|
||||
distanceFromStart: 0,
|
||||
containingCones: this.getContainingCones(start),
|
||||
},
|
||||
{
|
||||
position: goal,
|
||||
value: this.getValueAt(goal),
|
||||
distanceFromStart: distance(start, goal),
|
||||
containingCones: this.getContainingCones(goal),
|
||||
},
|
||||
];
|
||||
|
||||
return this.waypointsToPath(waypoints);
|
||||
}
|
||||
|
||||
private waypointsToPath(waypoints: PathWaypoint[]): PossibilityPath {
|
||||
const totalValue = waypoints.reduce((sum, w) => sum + w.value, 0);
|
||||
const length =
|
||||
waypoints.length > 0
|
||||
? waypoints[waypoints.length - 1].distanceFromStart
|
||||
: 0;
|
||||
|
||||
const satisfiedConstraints = this.constraints
|
||||
.filter((c) =>
|
||||
waypoints.every(
|
||||
(w) => signedDistanceToSurface(w.position, c.surface) <= 0
|
||||
)
|
||||
)
|
||||
.map((c) => c.id);
|
||||
|
||||
const violatedConstraints = this.constraints
|
||||
.filter((c) => !satisfiedConstraints.includes(c.id))
|
||||
.map((c) => c.id);
|
||||
|
||||
return {
|
||||
id: `path-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
waypoints,
|
||||
length,
|
||||
totalValue,
|
||||
riskExposure: this.calculateRiskExposure(waypoints),
|
||||
satisfiedConstraints,
|
||||
violatedConstraints,
|
||||
optimalityScore: this.scorePath({
|
||||
id: '',
|
||||
waypoints,
|
||||
length,
|
||||
totalValue,
|
||||
riskExposure: 0,
|
||||
satisfiedConstraints,
|
||||
violatedConstraints,
|
||||
optimalityScore: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private scorePath(path: PossibilityPath): number {
|
||||
const { weights } = this.config;
|
||||
|
||||
let score = 0;
|
||||
score += path.totalValue * weights.value;
|
||||
score -= path.length * weights.length;
|
||||
score -= path.riskExposure * weights.risk;
|
||||
score +=
|
||||
(path.satisfiedConstraints.length /
|
||||
Math.max(1, this.constraints.length)) *
|
||||
weights.constraints;
|
||||
|
||||
// Penalty for soft violations
|
||||
if (this.config.allowSoftViolations) {
|
||||
score -=
|
||||
path.violatedConstraints.filter((id) => {
|
||||
const c = this.constraints.find((c) => c.id === id);
|
||||
return c?.hardness === 'soft';
|
||||
}).length * this.config.softViolationPenalty;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private calculateRiskExposure(waypoints: PathWaypoint[]): number {
|
||||
return waypoints.reduce((sum, w) => sum + this.getRiskAt(w.position), 0) /
|
||||
Math.max(1, waypoints.length);
|
||||
}
|
||||
|
||||
private perturbPath(path: PossibilityPath): PossibilityPath {
|
||||
if (path.waypoints.length < 3) return path;
|
||||
|
||||
// Select random waypoint (not start/end)
|
||||
const index = 1 + Math.floor(Math.random() * (path.waypoints.length - 2));
|
||||
const waypoint = path.waypoints[index];
|
||||
|
||||
// Perturb position
|
||||
const perturbation = 0.5;
|
||||
const newPosition: SpacePoint = {
|
||||
coordinates: waypoint.position.coordinates.map(
|
||||
(c) => c + (Math.random() - 0.5) * perturbation
|
||||
),
|
||||
};
|
||||
|
||||
// Create new waypoints array
|
||||
const newWaypoints = [...path.waypoints];
|
||||
newWaypoints[index] = {
|
||||
...waypoint,
|
||||
position: newPosition,
|
||||
value: this.getValueAt(newPosition),
|
||||
containingCones: this.getContainingCones(newPosition),
|
||||
};
|
||||
|
||||
return this.waypointsToPath(newWaypoints);
|
||||
}
|
||||
|
||||
private createResult(
|
||||
path: PossibilityPath,
|
||||
iterations: number,
|
||||
converged: boolean,
|
||||
startTime: number
|
||||
): OptimizationResult {
|
||||
return {
|
||||
bestPath: path,
|
||||
alternatives: [], // Could generate Pareto frontier
|
||||
iterations,
|
||||
converged,
|
||||
metrics: {
|
||||
initialScore: 0,
|
||||
finalScore: path.optimalityScore,
|
||||
improvement: path.optimalityScore,
|
||||
runtime: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a path optimizer
|
||||
*/
|
||||
export function createPathOptimizer(
|
||||
bounds: { min: SpacePoint; max: SpacePoint },
|
||||
config?: Partial<OptimizationConfig>
|
||||
): PathOptimizer {
|
||||
return new PathOptimizer(bounds, config);
|
||||
}
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
/**
|
||||
* Constraint Pipeline
|
||||
*
|
||||
* Manages the propagation of constraints through a pipeline,
|
||||
* progressively narrowing possibility cones and computing
|
||||
* the valid solution space.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PossibilityCone,
|
||||
ConeConstraint,
|
||||
ConeIntersection,
|
||||
ConstraintPipeline,
|
||||
PipelineStage,
|
||||
SpacePoint,
|
||||
SpaceVector,
|
||||
ConicEvent,
|
||||
ConicEventListener,
|
||||
} from './types';
|
||||
import {
|
||||
createCone,
|
||||
narrowCone,
|
||||
isPointInCone,
|
||||
signedDistanceToSurface,
|
||||
estimateIntersectionVolume,
|
||||
findIntersectionWaist,
|
||||
} from './geometry';
|
||||
|
||||
// =============================================================================
|
||||
// Pipeline Manager
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for the constraint pipeline
|
||||
*/
|
||||
export interface PipelineConfig {
|
||||
/** Number of dimensions in possibility space */
|
||||
dimensions: number;
|
||||
|
||||
/** Dimension labels */
|
||||
dimensionLabels: string[];
|
||||
|
||||
/** Initial cone aperture (default = PI/4 = 45 degrees) */
|
||||
initialAperture: number;
|
||||
|
||||
/** Initial cone axis (default = time dimension) */
|
||||
initialAxis: number;
|
||||
|
||||
/** Bounds for volume estimation */
|
||||
bounds: {
|
||||
min: number[];
|
||||
max: number[];
|
||||
};
|
||||
|
||||
/** Sampling resolution for volume estimation */
|
||||
volumeSamples: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default pipeline configuration
|
||||
*/
|
||||
export const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
|
||||
dimensions: 4,
|
||||
dimensionLabels: ['Time', 'Value', 'Risk', 'Resources'],
|
||||
initialAperture: Math.PI / 4,
|
||||
initialAxis: 0, // Time
|
||||
bounds: {
|
||||
min: [0, 0, 0, 0],
|
||||
max: [100, 100, 100, 100],
|
||||
},
|
||||
volumeSamples: 5000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages constraint pipeline execution
|
||||
*/
|
||||
export class ConstraintPipelineManager {
|
||||
private config: PipelineConfig;
|
||||
private pipelines: Map<string, ConstraintPipeline> = new Map();
|
||||
private listeners: Set<ConicEventListener> = new Set();
|
||||
|
||||
constructor(config: Partial<PipelineConfig> = {}) {
|
||||
this.config = { ...DEFAULT_PIPELINE_CONFIG, ...config };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Pipeline Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create a new pipeline
|
||||
*/
|
||||
createPipeline(
|
||||
name: string,
|
||||
origin: SpacePoint,
|
||||
axis?: SpaceVector
|
||||
): ConstraintPipeline {
|
||||
const id = `pipeline-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Default axis along first dimension
|
||||
const defaultAxis: SpaceVector = {
|
||||
components: new Array(this.config.dimensions)
|
||||
.fill(0)
|
||||
.map((_, i) => (i === this.config.initialAxis ? 1 : 0)),
|
||||
};
|
||||
|
||||
const initialCone = createCone({
|
||||
apex: origin,
|
||||
axis: axis ?? defaultAxis,
|
||||
aperture: this.config.initialAperture,
|
||||
direction: 'forward',
|
||||
});
|
||||
|
||||
const pipeline: ConstraintPipeline = {
|
||||
id,
|
||||
name,
|
||||
stages: [],
|
||||
initialCone,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
this.pipelines.set(id, pipeline);
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a constraint stage to pipeline
|
||||
*/
|
||||
addStage(
|
||||
pipelineId: string,
|
||||
stageName: string,
|
||||
constraints: ConeConstraint[]
|
||||
): PipelineStage | null {
|
||||
const pipeline = this.pipelines.get(pipelineId);
|
||||
if (!pipeline) return null;
|
||||
|
||||
const position = pipeline.stages.length;
|
||||
|
||||
const stage: PipelineStage = {
|
||||
id: `stage-${position}-${Date.now()}`,
|
||||
name: stageName,
|
||||
position,
|
||||
constraints,
|
||||
};
|
||||
|
||||
pipeline.stages.push(stage);
|
||||
|
||||
// Process stage
|
||||
this.processStage(pipeline, stage);
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a pipeline stage
|
||||
*/
|
||||
private processStage(pipeline: ConstraintPipeline, stage: PipelineStage): void {
|
||||
// Get the cone from previous stage
|
||||
const previousCone =
|
||||
stage.position === 0
|
||||
? pipeline.initialCone
|
||||
: pipeline.stages[stage.position - 1].resultingCone ?? pipeline.initialCone;
|
||||
|
||||
// Apply constraints to narrow the cone
|
||||
let resultingCone = previousCone;
|
||||
|
||||
for (const constraint of stage.constraints) {
|
||||
// Calculate how much this constraint narrows the cone
|
||||
const narrowingFactor = 1 - constraint.restrictiveness;
|
||||
resultingCone = narrowCone(resultingCone, narrowingFactor, constraint.id);
|
||||
}
|
||||
|
||||
stage.resultingCone = resultingCone;
|
||||
|
||||
// Estimate remaining volume fraction
|
||||
const initialVolume = this.estimateVolume([pipeline.initialCone]);
|
||||
const currentVolume = this.estimateVolume([resultingCone]);
|
||||
stage.remainingVolumeFraction =
|
||||
initialVolume > 0 ? currentVolume / initialVolume : 0;
|
||||
|
||||
this.emit({ type: 'pipeline:stage-completed', stage });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full pipeline and compute final intersection
|
||||
*/
|
||||
runPipeline(pipelineId: string): ConeIntersection | null {
|
||||
const pipeline = this.pipelines.get(pipelineId);
|
||||
if (!pipeline || pipeline.stages.length === 0) return null;
|
||||
|
||||
// Collect all resulting cones
|
||||
const cones: PossibilityCone[] = [];
|
||||
let lastCone = pipeline.initialCone;
|
||||
|
||||
for (const stage of pipeline.stages) {
|
||||
if (stage.resultingCone) {
|
||||
cones.push(stage.resultingCone);
|
||||
lastCone = stage.resultingCone;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute intersection
|
||||
const intersection = this.computeIntersection(cones, pipeline);
|
||||
pipeline.finalIntersection = intersection;
|
||||
|
||||
this.emit({ type: 'intersection:computed', intersection });
|
||||
|
||||
return intersection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute cone intersection
|
||||
*/
|
||||
private computeIntersection(
|
||||
cones: PossibilityCone[],
|
||||
pipeline: ConstraintPipeline
|
||||
): ConeIntersection {
|
||||
const bounds = this.getBounds();
|
||||
|
||||
// Estimate volume
|
||||
const volume = this.estimateVolume(cones);
|
||||
|
||||
// Find waist
|
||||
const waist = findIntersectionWaist(
|
||||
cones,
|
||||
this.config.initialAxis,
|
||||
bounds,
|
||||
50
|
||||
);
|
||||
|
||||
const intersection: ConeIntersection = {
|
||||
id: `intersection-${Date.now()}`,
|
||||
coneIds: cones.map((c) => c.id),
|
||||
constraintIds: pipeline.stages.flatMap((s) => s.constraints.map((c) => c.id)),
|
||||
volume,
|
||||
waist: waist ?? undefined,
|
||||
boundary: {
|
||||
type: 'implicit',
|
||||
bounds,
|
||||
},
|
||||
};
|
||||
|
||||
if (waist) {
|
||||
this.emit({ type: 'waist:detected', waist });
|
||||
}
|
||||
|
||||
return intersection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate volume of cone intersection
|
||||
*/
|
||||
private estimateVolume(cones: PossibilityCone[]): number {
|
||||
return estimateIntersectionVolume(
|
||||
cones,
|
||||
this.getBounds(),
|
||||
this.config.volumeSamples
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounds as SpacePoints
|
||||
*/
|
||||
private getBounds(): { min: SpacePoint; max: SpacePoint } {
|
||||
return {
|
||||
min: { coordinates: this.config.bounds.min },
|
||||
max: { coordinates: this.config.bounds.max },
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Query Methods
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get a pipeline by ID
|
||||
*/
|
||||
getPipeline(id: string): ConstraintPipeline | undefined {
|
||||
return this.pipelines.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pipelines
|
||||
*/
|
||||
getAllPipelines(): ConstraintPipeline[] {
|
||||
return Array.from(this.pipelines.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point is valid (in all pipeline intersections)
|
||||
*/
|
||||
isPointValid(pipelineId: string, point: SpacePoint): boolean {
|
||||
const pipeline = this.pipelines.get(pipelineId);
|
||||
if (!pipeline) return false;
|
||||
|
||||
// Check against all stage cones
|
||||
for (const stage of pipeline.stages) {
|
||||
if (stage.resultingCone && !isPointInCone(point, stage.resultingCone)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check against all constraints
|
||||
for (const stage of pipeline.stages) {
|
||||
for (const constraint of stage.constraints) {
|
||||
if (
|
||||
constraint.hardness === 'hard' &&
|
||||
signedDistanceToSurface(point, constraint.surface) > 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get constraint violation score for a point
|
||||
*/
|
||||
getViolationScore(pipelineId: string, point: SpacePoint): number {
|
||||
const pipeline = this.pipelines.get(pipelineId);
|
||||
if (!pipeline) return Infinity;
|
||||
|
||||
let totalViolation = 0;
|
||||
|
||||
for (const stage of pipeline.stages) {
|
||||
for (const constraint of stage.constraints) {
|
||||
const dist = signedDistanceToSurface(point, constraint.surface);
|
||||
if (dist > 0) {
|
||||
// Violation
|
||||
const weight = constraint.hardness === 'hard' ? 1000 : constraint.weight ?? 1;
|
||||
totalViolation += dist * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalViolation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the narrowest point (bottleneck) in the pipeline
|
||||
*/
|
||||
getBottleneck(pipelineId: string): PipelineStage | null {
|
||||
const pipeline = this.pipelines.get(pipelineId);
|
||||
if (!pipeline) return null;
|
||||
|
||||
let narrowestStage: PipelineStage | null = null;
|
||||
let minVolumeFraction = Infinity;
|
||||
|
||||
for (const stage of pipeline.stages) {
|
||||
if (
|
||||
stage.remainingVolumeFraction !== undefined &&
|
||||
stage.remainingVolumeFraction < minVolumeFraction
|
||||
) {
|
||||
minVolumeFraction = stage.remainingVolumeFraction;
|
||||
narrowestStage = stage;
|
||||
}
|
||||
}
|
||||
|
||||
return narrowestStage;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Events
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(listener: ConicEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private emit(event: ConicEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('Error in conic event listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Serialization
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Export pipeline to JSON
|
||||
*/
|
||||
exportPipeline(pipelineId: string): string | null {
|
||||
const pipeline = this.pipelines.get(pipelineId);
|
||||
if (!pipeline) return null;
|
||||
return JSON.stringify(pipeline, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import pipeline from JSON
|
||||
*/
|
||||
importPipeline(json: string): ConstraintPipeline | null {
|
||||
try {
|
||||
const pipeline = JSON.parse(json) as ConstraintPipeline;
|
||||
this.pipelines.set(pipeline.id, pipeline);
|
||||
return pipeline;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dependency Analysis
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Analyze constraint dependencies
|
||||
*/
|
||||
export function analyzeConstraintDependencies(
|
||||
constraints: ConeConstraint[]
|
||||
): {
|
||||
order: ConeConstraint[];
|
||||
cycles: string[][];
|
||||
parallelGroups: ConeConstraint[][];
|
||||
} {
|
||||
// Build dependency graph
|
||||
const graph = new Map<string, Set<string>>();
|
||||
const constraintMap = new Map<string, ConeConstraint>();
|
||||
|
||||
for (const c of constraints) {
|
||||
constraintMap.set(c.id, c);
|
||||
graph.set(c.id, new Set(c.dependencies));
|
||||
}
|
||||
|
||||
// Topological sort with cycle detection
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const order: ConeConstraint[] = [];
|
||||
const cycles: string[][] = [];
|
||||
|
||||
function dfs(id: string, path: string[]): boolean {
|
||||
if (recursionStack.has(id)) {
|
||||
// Cycle detected
|
||||
const cycleStart = path.indexOf(id);
|
||||
cycles.push(path.slice(cycleStart));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (visited.has(id)) return true;
|
||||
|
||||
visited.add(id);
|
||||
recursionStack.add(id);
|
||||
|
||||
const deps = graph.get(id) ?? new Set();
|
||||
for (const dep of deps) {
|
||||
if (!dfs(dep, [...path, id])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(id);
|
||||
const constraint = constraintMap.get(id);
|
||||
if (constraint) {
|
||||
order.push(constraint);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const id of constraintMap.keys()) {
|
||||
if (!visited.has(id)) {
|
||||
dfs(id, []);
|
||||
}
|
||||
}
|
||||
|
||||
// Find parallel groups (constraints with same dependencies)
|
||||
const depSignature = (c: ConeConstraint) =>
|
||||
[...c.dependencies].sort().join(',');
|
||||
|
||||
const signatureGroups = new Map<string, ConeConstraint[]>();
|
||||
for (const c of constraints) {
|
||||
const sig = depSignature(c);
|
||||
const group = signatureGroups.get(sig) ?? [];
|
||||
group.push(c);
|
||||
signatureGroups.set(sig, group);
|
||||
}
|
||||
|
||||
const parallelGroups = Array.from(signatureGroups.values()).filter(
|
||||
(g) => g.length > 1
|
||||
);
|
||||
|
||||
return {
|
||||
order: order.reverse(),
|
||||
cycles,
|
||||
parallelGroups,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a constraint pipeline manager
|
||||
*/
|
||||
export function createPipelineManager(
|
||||
config?: Partial<PipelineConfig>
|
||||
): ConstraintPipelineManager {
|
||||
return new ConstraintPipelineManager(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple constraint
|
||||
*/
|
||||
export function createConstraint(params: {
|
||||
label: string;
|
||||
type: ConeConstraint['type'];
|
||||
restrictiveness: number;
|
||||
hardness?: 'hard' | 'soft';
|
||||
dependencies?: string[];
|
||||
surface?: ConeConstraint['surface'];
|
||||
}): ConeConstraint {
|
||||
return {
|
||||
id: `constraint-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
label: params.label,
|
||||
type: params.type,
|
||||
pipelinePosition: 0,
|
||||
surface: params.surface ?? {
|
||||
type: 'hyperplane',
|
||||
normal: { components: [1, 0, 0, 0] },
|
||||
offset: 0,
|
||||
validSide: 'positive',
|
||||
},
|
||||
restrictiveness: params.restrictiveness,
|
||||
dependencies: params.dependencies ?? [],
|
||||
hardness: params.hardness ?? 'hard',
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,613 @@
|
|||
/**
|
||||
* Possibility Cones and Constraint Propagation
|
||||
*
|
||||
* A mathematical framework for visualizing how constraints propagate
|
||||
* through decision pipelines. Each decision point creates a "possibility
|
||||
* cone" - a light-cone-like structure representing reachable futures.
|
||||
* Subsequent constraints act as apertures that narrow these cones.
|
||||
*
|
||||
* The intersection of overlapping cones from multiple constraints
|
||||
* defines the valid solution manifold, through which we can find
|
||||
* value-weighted optimal paths.
|
||||
*
|
||||
* Concepts:
|
||||
* - Forward cones: Future possibilities from a decision point
|
||||
* - Backward cones: Past decisions that could lead to a state
|
||||
* - Apertures: Constraint surfaces that narrow cones
|
||||
* - Waist: The narrowest point where cones meet (bottleneck)
|
||||
* - Caustics: Where many cone edges converge (critical points)
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Dimensional Space
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A point in n-dimensional possibility space
|
||||
* Dimensions might include: time, value, risk, resources, etc.
|
||||
*/
|
||||
export interface SpacePoint {
|
||||
/** Dimension values */
|
||||
coordinates: number[];
|
||||
|
||||
/** Dimension labels */
|
||||
dimensions?: string[];
|
||||
|
||||
/** Optional weight/probability at this point */
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard dimension indices for common use cases
|
||||
*/
|
||||
export const DIMENSION = {
|
||||
TIME: 0,
|
||||
VALUE: 1,
|
||||
RISK: 2,
|
||||
RESOURCE: 3,
|
||||
ATTENTION: 4,
|
||||
TRUST: 5,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A vector in possibility space
|
||||
*/
|
||||
export interface SpaceVector {
|
||||
components: number[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cone Primitives
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Direction of a possibility cone
|
||||
*/
|
||||
export type ConeDirection = 'forward' | 'backward' | 'bidirectional';
|
||||
|
||||
/**
|
||||
* A possibility cone in n-dimensional space
|
||||
*
|
||||
* Geometrically: a cone with apex at origin, opening in a direction,
|
||||
* with an opening angle that defines how possibilities spread.
|
||||
*/
|
||||
export interface PossibilityCone {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Apex of the cone (decision point) */
|
||||
apex: SpacePoint;
|
||||
|
||||
/** Primary axis direction (unit vector) */
|
||||
axis: SpaceVector;
|
||||
|
||||
/** Opening half-angle in radians (0 = laser, PI/2 = hemisphere) */
|
||||
aperture: number;
|
||||
|
||||
/** Direction the cone opens */
|
||||
direction: ConeDirection;
|
||||
|
||||
/** Maximum extent along axis (null = infinite) */
|
||||
extent: number | null;
|
||||
|
||||
/** Value gradient along the cone (center to edge) */
|
||||
valueGradient?: {
|
||||
center: number; // Value at axis
|
||||
edge: number; // Value at cone surface
|
||||
falloff: 'linear' | 'quadratic' | 'exponential';
|
||||
};
|
||||
|
||||
/** Constraints that shaped this cone */
|
||||
constraints: string[];
|
||||
|
||||
/** Metadata */
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A constraint that narrows a possibility cone
|
||||
*/
|
||||
export interface ConeConstraint {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
|
||||
/** Type of constraint */
|
||||
type: ConstraintType;
|
||||
|
||||
/** Position along the pipeline (for ordering) */
|
||||
pipelinePosition: number;
|
||||
|
||||
/** The constraint surface/condition */
|
||||
surface: ConstraintSurface;
|
||||
|
||||
/** How much this constraint typically narrows cones (0-1) */
|
||||
restrictiveness: number;
|
||||
|
||||
/** Dependencies on other constraints */
|
||||
dependencies: string[];
|
||||
|
||||
/** Whether constraint is hard (must satisfy) or soft (prefer) */
|
||||
hardness: 'hard' | 'soft';
|
||||
|
||||
/** Weight for soft constraints */
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of constraints
|
||||
*/
|
||||
export type ConstraintType =
|
||||
| 'temporal' // Time-based deadline
|
||||
| 'resource' // Resource availability
|
||||
| 'dependency' // Must come after X
|
||||
| 'exclusion' // Cannot coexist with Y
|
||||
| 'capacity' // Maximum throughput
|
||||
| 'quality' // Minimum quality threshold
|
||||
| 'risk' // Maximum risk tolerance
|
||||
| 'value' // Minimum value threshold
|
||||
| 'custom'; // User-defined
|
||||
|
||||
/**
|
||||
* A surface that defines a constraint
|
||||
* Can be a hyperplane, sphere, or more complex manifold
|
||||
*/
|
||||
export type ConstraintSurface =
|
||||
| HyperplaneSurface
|
||||
| SphereSurface
|
||||
| ConeSurface
|
||||
| CustomSurface;
|
||||
|
||||
export interface HyperplaneSurface {
|
||||
type: 'hyperplane';
|
||||
/** Normal vector to the plane */
|
||||
normal: SpaceVector;
|
||||
/** Distance from origin */
|
||||
offset: number;
|
||||
/** Which side is valid ('positive' | 'negative' | 'both') */
|
||||
validSide: 'positive' | 'negative';
|
||||
}
|
||||
|
||||
export interface SphereSurface {
|
||||
type: 'sphere';
|
||||
/** Center of sphere */
|
||||
center: SpacePoint;
|
||||
/** Radius */
|
||||
radius: number;
|
||||
/** Is inside or outside valid? */
|
||||
validRegion: 'inside' | 'outside';
|
||||
}
|
||||
|
||||
export interface ConeSurface {
|
||||
type: 'cone';
|
||||
/** The cone that defines the surface */
|
||||
cone: PossibilityCone;
|
||||
/** Is inside or outside the cone valid? */
|
||||
validRegion: 'inside' | 'outside';
|
||||
}
|
||||
|
||||
export interface CustomSurface {
|
||||
type: 'custom';
|
||||
/** Function that returns signed distance to surface */
|
||||
signedDistanceFn: string; // Serialized function reference
|
||||
/** Parameters for the function */
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Conic Sections
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Type of conic section (2D slice through a cone)
|
||||
*/
|
||||
export type ConicSectionType =
|
||||
| 'circle' // Slice perpendicular to axis
|
||||
| 'ellipse' // Angled slice, not through apex
|
||||
| 'parabola' // Slice parallel to cone edge
|
||||
| 'hyperbola' // Steep slice through both nappes
|
||||
| 'point' // Slice through apex only
|
||||
| 'line' // Degenerate case
|
||||
| 'crossed-lines'; // Two lines through apex
|
||||
|
||||
/**
|
||||
* A conic section (2D representation)
|
||||
*/
|
||||
export interface ConicSection {
|
||||
/** Section type */
|
||||
type: ConicSectionType;
|
||||
|
||||
/** Center point (in slice plane) */
|
||||
center: { x: number; y: number };
|
||||
|
||||
/** For ellipse/hyperbola: semi-major axis */
|
||||
a?: number;
|
||||
|
||||
/** For ellipse/hyperbola: semi-minor axis */
|
||||
b?: number;
|
||||
|
||||
/** Rotation angle in radians */
|
||||
rotation: number;
|
||||
|
||||
/** For parabola: focal parameter */
|
||||
p?: number;
|
||||
|
||||
/** Eccentricity (0=circle, 0<e<1=ellipse, 1=parabola, >1=hyperbola) */
|
||||
eccentricity: number;
|
||||
|
||||
/** The slicing plane that created this section */
|
||||
slicePlane?: {
|
||||
normal: SpaceVector;
|
||||
offset: number;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cone Intersections
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The intersection of multiple possibility cones
|
||||
* This represents the valid solution space
|
||||
*/
|
||||
export interface ConeIntersection {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Cones that form this intersection */
|
||||
coneIds: string[];
|
||||
|
||||
/** Constraints that shaped these cones */
|
||||
constraintIds: string[];
|
||||
|
||||
/** Approximate volume of intersection (normalized) */
|
||||
volume: number;
|
||||
|
||||
/** The "waist" - narrowest cross-section */
|
||||
waist?: {
|
||||
position: SpacePoint;
|
||||
area: number;
|
||||
};
|
||||
|
||||
/** Boundary representation */
|
||||
boundary: IntersectionBoundary;
|
||||
|
||||
/** Value distribution within intersection */
|
||||
valueField?: ValueField;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary of an intersection region
|
||||
*/
|
||||
export interface IntersectionBoundary {
|
||||
/** Type of boundary representation */
|
||||
type: 'mesh' | 'implicit' | 'parametric';
|
||||
|
||||
/** For mesh: vertices and faces */
|
||||
mesh?: {
|
||||
vertices: SpacePoint[];
|
||||
faces: number[][]; // Indices into vertices
|
||||
};
|
||||
|
||||
/** For implicit: signed distance function */
|
||||
implicitFn?: string;
|
||||
|
||||
/** Bounding box */
|
||||
bounds: {
|
||||
min: SpacePoint;
|
||||
max: SpacePoint;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A scalar field of values within a region
|
||||
*/
|
||||
export interface ValueField {
|
||||
/** Sampling resolution */
|
||||
resolution: number[];
|
||||
|
||||
/** Sampled values (flattened n-dimensional array) */
|
||||
values: number[];
|
||||
|
||||
/** Interpolation method */
|
||||
interpolation: 'nearest' | 'linear' | 'cubic';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pipeline and Paths
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A pipeline of constraints that progressively narrow possibilities
|
||||
*/
|
||||
export interface ConstraintPipeline {
|
||||
/** Pipeline identifier */
|
||||
id: string;
|
||||
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
|
||||
/** Ordered constraints */
|
||||
stages: PipelineStage[];
|
||||
|
||||
/** Initial cone (unconstrained possibilities) */
|
||||
initialCone: PossibilityCone;
|
||||
|
||||
/** Final intersection after all constraints */
|
||||
finalIntersection?: ConeIntersection;
|
||||
|
||||
/** Metadata */
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stage in the constraint pipeline
|
||||
*/
|
||||
export interface PipelineStage {
|
||||
/** Stage identifier */
|
||||
id: string;
|
||||
|
||||
/** Stage name */
|
||||
name: string;
|
||||
|
||||
/** Position in pipeline (0-indexed) */
|
||||
position: number;
|
||||
|
||||
/** Constraints applied at this stage */
|
||||
constraints: ConeConstraint[];
|
||||
|
||||
/** Cone after applying this stage's constraints */
|
||||
resultingCone?: PossibilityCone;
|
||||
|
||||
/** Intersection volume after this stage (as fraction of initial) */
|
||||
remainingVolumeFraction?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A path through the possibility space
|
||||
*/
|
||||
export interface PossibilityPath {
|
||||
/** Path identifier */
|
||||
id: string;
|
||||
|
||||
/** Sequence of waypoints */
|
||||
waypoints: PathWaypoint[];
|
||||
|
||||
/** Total path length */
|
||||
length: number;
|
||||
|
||||
/** Accumulated value along path */
|
||||
totalValue: number;
|
||||
|
||||
/** Risk exposure along path */
|
||||
riskExposure: number;
|
||||
|
||||
/** Constraints satisfied */
|
||||
satisfiedConstraints: string[];
|
||||
|
||||
/** Constraints violated (for soft constraints) */
|
||||
violatedConstraints: string[];
|
||||
|
||||
/** Path optimality score */
|
||||
optimalityScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A waypoint along a path
|
||||
*/
|
||||
export interface PathWaypoint {
|
||||
/** Position in space */
|
||||
position: SpacePoint;
|
||||
|
||||
/** Value at this point */
|
||||
value: number;
|
||||
|
||||
/** Distance from path start */
|
||||
distanceFromStart: number;
|
||||
|
||||
/** Which cones contain this point */
|
||||
containingCones: string[];
|
||||
|
||||
/** Gradient direction toward higher value */
|
||||
valueGradient?: SpaceVector;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Optimization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for path optimization
|
||||
*/
|
||||
export interface OptimizationConfig {
|
||||
/** Objective function weights */
|
||||
weights: {
|
||||
value: number; // Maximize accumulated value
|
||||
length: number; // Minimize path length
|
||||
risk: number; // Minimize risk exposure
|
||||
constraints: number; // Maximize constraints satisfied
|
||||
};
|
||||
|
||||
/** Algorithm to use */
|
||||
algorithm:
|
||||
| 'gradient-descent'
|
||||
| 'simulated-annealing'
|
||||
| 'genetic'
|
||||
| 'dijkstra'
|
||||
| 'a-star';
|
||||
|
||||
/** Maximum iterations */
|
||||
maxIterations: number;
|
||||
|
||||
/** Convergence threshold */
|
||||
convergenceThreshold: number;
|
||||
|
||||
/** Sampling resolution for discretization */
|
||||
samplingResolution: number;
|
||||
|
||||
/** Whether to allow soft constraint violations */
|
||||
allowSoftViolations: boolean;
|
||||
|
||||
/** Penalty multiplier for soft violations */
|
||||
softViolationPenalty: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of path optimization
|
||||
*/
|
||||
export interface OptimizationResult {
|
||||
/** Best path found */
|
||||
bestPath: PossibilityPath;
|
||||
|
||||
/** Alternative paths (Pareto frontier) */
|
||||
alternatives: PossibilityPath[];
|
||||
|
||||
/** Iterations taken */
|
||||
iterations: number;
|
||||
|
||||
/** Whether converged */
|
||||
converged: boolean;
|
||||
|
||||
/** Optimization metrics */
|
||||
metrics: {
|
||||
initialScore: number;
|
||||
finalScore: number;
|
||||
improvement: number;
|
||||
runtime: number;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Visualization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Projection mode for visualizing n-dimensional cones
|
||||
*/
|
||||
export type ProjectionMode =
|
||||
| 'orthographic' // Parallel projection
|
||||
| 'perspective' // Perspective projection
|
||||
| 'stereographic' // Preserves angles
|
||||
| 'slice'; // 2D slice at specific position
|
||||
|
||||
/**
|
||||
* Visualization configuration for conic structures
|
||||
*/
|
||||
export interface ConicVisualization {
|
||||
/** Projection mode */
|
||||
projection: ProjectionMode;
|
||||
|
||||
/** Which dimensions to display */
|
||||
displayDimensions: [number, number] | [number, number, number];
|
||||
|
||||
/** Slice position for other dimensions */
|
||||
slicePositions: Record<number, number>;
|
||||
|
||||
/** Color scheme */
|
||||
colors: {
|
||||
coneInterior: string;
|
||||
coneSurface: string;
|
||||
constraintSurface: string;
|
||||
validRegion: string;
|
||||
invalidRegion: string;
|
||||
optimalPath: string;
|
||||
valueHigh: string;
|
||||
valueLow: string;
|
||||
};
|
||||
|
||||
/** Opacity settings */
|
||||
opacity: {
|
||||
cones: number;
|
||||
constraints: number;
|
||||
intersection: number;
|
||||
};
|
||||
|
||||
/** Whether to show various elements */
|
||||
show: {
|
||||
coneEdges: boolean;
|
||||
constraintSurfaces: boolean;
|
||||
intersection: boolean;
|
||||
valueGradient: boolean;
|
||||
paths: boolean;
|
||||
waypoints: boolean;
|
||||
waist: boolean;
|
||||
caustics: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Events
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Events from the conic system
|
||||
*/
|
||||
export type ConicEvent =
|
||||
| { type: 'cone:created'; cone: PossibilityCone }
|
||||
| { type: 'cone:updated'; cone: PossibilityCone }
|
||||
| { type: 'constraint:added'; constraint: ConeConstraint }
|
||||
| { type: 'constraint:removed'; constraintId: string }
|
||||
| { type: 'intersection:computed'; intersection: ConeIntersection }
|
||||
| { type: 'path:optimized'; result: OptimizationResult }
|
||||
| { type: 'pipeline:stage-completed'; stage: PipelineStage }
|
||||
| { type: 'waist:detected'; waist: ConeIntersection['waist'] };
|
||||
|
||||
export type ConicEventListener = (event: ConicEvent) => void;
|
||||
|
||||
// =============================================================================
|
||||
// Defaults
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Default optimization configuration
|
||||
*/
|
||||
export const DEFAULT_OPTIMIZATION_CONFIG: OptimizationConfig = {
|
||||
weights: {
|
||||
value: 1.0,
|
||||
length: 0.3,
|
||||
risk: 0.5,
|
||||
constraints: 0.8,
|
||||
},
|
||||
algorithm: 'a-star',
|
||||
maxIterations: 1000,
|
||||
convergenceThreshold: 0.001,
|
||||
samplingResolution: 20,
|
||||
allowSoftViolations: true,
|
||||
softViolationPenalty: 0.5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default visualization configuration
|
||||
*/
|
||||
export const DEFAULT_CONIC_VISUALIZATION: ConicVisualization = {
|
||||
projection: 'perspective',
|
||||
displayDimensions: [0, 1, 2], // Time, Value, Risk
|
||||
slicePositions: {},
|
||||
colors: {
|
||||
coneInterior: '#3b82f680',
|
||||
coneSurface: '#3b82f6',
|
||||
constraintSurface: '#f59e0b',
|
||||
validRegion: '#22c55e40',
|
||||
invalidRegion: '#ef444420',
|
||||
optimalPath: '#ec4899',
|
||||
valueHigh: '#22c55e',
|
||||
valueLow: '#6b7280',
|
||||
},
|
||||
opacity: {
|
||||
cones: 0.3,
|
||||
constraints: 0.5,
|
||||
intersection: 0.4,
|
||||
},
|
||||
show: {
|
||||
coneEdges: true,
|
||||
constraintSurfaces: true,
|
||||
intersection: true,
|
||||
valueGradient: true,
|
||||
paths: true,
|
||||
waypoints: true,
|
||||
waist: true,
|
||||
caustics: false,
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,758 @@
|
|||
/**
|
||||
* Discovery Anchor Management
|
||||
*
|
||||
* Create, manage, and verify discovery anchors - the hidden or
|
||||
* semi-hidden locations that players can discover using zkGPS proofs.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DiscoveryAnchor,
|
||||
AnchorType,
|
||||
AnchorVisibility,
|
||||
AnchorHint,
|
||||
DiscoveryReward,
|
||||
IoTRequirement,
|
||||
SocialRequirement,
|
||||
HintContent,
|
||||
HintRevealCondition,
|
||||
Discovery,
|
||||
NavigationHint,
|
||||
GameEvent,
|
||||
GameEventListener,
|
||||
} from './types';
|
||||
import { TEMPERATURE_THRESHOLDS } from './types';
|
||||
import type { GeohashCommitment, ProximityProof } from '../privacy/types';
|
||||
import {
|
||||
createCommitment,
|
||||
verifyCommitment,
|
||||
generateProximityProof,
|
||||
verifyProximityProof,
|
||||
} from '../privacy';
|
||||
|
||||
// =============================================================================
|
||||
// Anchor Manager
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for anchor manager
|
||||
*/
|
||||
export interface AnchorManagerConfig {
|
||||
/** Default precision required for discovery */
|
||||
defaultPrecision: number;
|
||||
|
||||
/** Maximum hints per anchor */
|
||||
maxHintsPerAnchor: number;
|
||||
|
||||
/** Allow IoT-free discoveries */
|
||||
allowVirtualDiscoveries: boolean;
|
||||
|
||||
/** Minimum time between discoveries at same anchor */
|
||||
cooldownSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
export const DEFAULT_ANCHOR_CONFIG: AnchorManagerConfig = {
|
||||
defaultPrecision: 7, // ~76m accuracy
|
||||
maxHintsPerAnchor: 10,
|
||||
allowVirtualDiscoveries: true,
|
||||
cooldownSeconds: 60,
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages discovery anchors
|
||||
*/
|
||||
export class AnchorManager {
|
||||
private config: AnchorManagerConfig;
|
||||
private anchors: Map<string, DiscoveryAnchor> = new Map();
|
||||
private discoveries: Map<string, Discovery[]> = new Map(); // anchorId -> discoveries
|
||||
private listeners: Set<GameEventListener> = new Set();
|
||||
|
||||
constructor(config: Partial<AnchorManagerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_ANCHOR_CONFIG, ...config };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Anchor Creation
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create a new discovery anchor
|
||||
*/
|
||||
async createAnchor(params: {
|
||||
name: string;
|
||||
description: string;
|
||||
type: AnchorType;
|
||||
visibility: AnchorVisibility;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
precision?: number;
|
||||
creatorPubKey: string;
|
||||
creatorPrivKey: string;
|
||||
activeWindow?: DiscoveryAnchor['activeWindow'];
|
||||
iotRequirements?: IoTRequirement[];
|
||||
socialRequirements?: SocialRequirement;
|
||||
rewards?: DiscoveryReward[];
|
||||
hints?: AnchorHint[];
|
||||
prerequisites?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<DiscoveryAnchor> {
|
||||
const id = `anchor-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Create zkGPS commitment for the location
|
||||
const locationCommitment = await createCommitment(
|
||||
params.latitude,
|
||||
params.longitude,
|
||||
12, // Full precision internally
|
||||
params.creatorPubKey,
|
||||
params.creatorPrivKey
|
||||
);
|
||||
|
||||
const anchor: DiscoveryAnchor = {
|
||||
id,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
type: params.type,
|
||||
visibility: params.visibility,
|
||||
locationCommitment,
|
||||
requiredPrecision: params.precision ?? this.config.defaultPrecision,
|
||||
activeWindow: params.activeWindow,
|
||||
iotRequirements: params.iotRequirements,
|
||||
socialRequirements: params.socialRequirements,
|
||||
rewards: params.rewards ?? [],
|
||||
hints: params.hints ?? [],
|
||||
prerequisites: params.prerequisites ?? [],
|
||||
metadata: params.metadata ?? {},
|
||||
creatorPubKey: params.creatorPubKey,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.anchors.set(id, anchor);
|
||||
this.discoveries.set(id, []);
|
||||
|
||||
this.emit({ type: 'anchor:created', anchor });
|
||||
|
||||
return anchor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a hint to an anchor
|
||||
*/
|
||||
addHint(anchorId: string, hint: Omit<AnchorHint, 'id'>): AnchorHint | null {
|
||||
const anchor = this.anchors.get(anchorId);
|
||||
if (!anchor) return null;
|
||||
|
||||
if (anchor.hints.length >= this.config.maxHintsPerAnchor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullHint: AnchorHint = {
|
||||
...hint,
|
||||
id: `hint-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
};
|
||||
|
||||
anchor.hints.push(fullHint);
|
||||
return fullHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add rewards to an anchor
|
||||
*/
|
||||
addReward(anchorId: string, reward: DiscoveryReward): boolean {
|
||||
const anchor = this.anchors.get(anchorId);
|
||||
if (!anchor) return false;
|
||||
|
||||
anchor.rewards.push(reward);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Discovery Verification
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Attempt to discover an anchor
|
||||
*/
|
||||
async attemptDiscovery(params: {
|
||||
anchorId: string;
|
||||
playerPubKey: string;
|
||||
playerPrivKey: string;
|
||||
playerLatitude: number;
|
||||
playerLongitude: number;
|
||||
iotVerification?: Discovery['iotVerification'];
|
||||
groupDiscovery?: Discovery['groupDiscovery'];
|
||||
}): Promise<{ success: boolean; discovery?: Discovery; error?: string }> {
|
||||
const anchor = this.anchors.get(params.anchorId);
|
||||
if (!anchor) {
|
||||
return { success: false, error: 'Anchor not found' };
|
||||
}
|
||||
|
||||
// Check prerequisites
|
||||
const prereqCheck = this.checkPrerequisites(anchor, params.playerPubKey);
|
||||
if (!prereqCheck.met) {
|
||||
return { success: false, error: `Missing prerequisites: ${prereqCheck.missing.join(', ')}` };
|
||||
}
|
||||
|
||||
// Check time window
|
||||
if (anchor.activeWindow) {
|
||||
const now = new Date();
|
||||
if (now < anchor.activeWindow.start || now > anchor.activeWindow.end) {
|
||||
return { success: false, error: 'Anchor not active at this time' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check IoT requirements
|
||||
if (anchor.iotRequirements && anchor.iotRequirements.length > 0) {
|
||||
if (!params.iotVerification) {
|
||||
return { success: false, error: 'IoT verification required' };
|
||||
}
|
||||
const iotValid = this.verifyIoT(anchor.iotRequirements, params.iotVerification);
|
||||
if (!iotValid) {
|
||||
return { success: false, error: 'IoT verification failed' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check social requirements
|
||||
if (anchor.socialRequirements) {
|
||||
if (!params.groupDiscovery) {
|
||||
return { success: false, error: 'Group discovery required' };
|
||||
}
|
||||
const socialValid = this.verifySocialRequirements(
|
||||
anchor.socialRequirements,
|
||||
params.groupDiscovery
|
||||
);
|
||||
if (!socialValid.valid) {
|
||||
return { success: false, error: socialValid.error };
|
||||
}
|
||||
}
|
||||
|
||||
// Generate proximity proof
|
||||
const proximityProof = await generateProximityProof(
|
||||
params.playerLatitude,
|
||||
params.playerLongitude,
|
||||
anchor.locationCommitment,
|
||||
anchor.requiredPrecision,
|
||||
params.playerPubKey,
|
||||
params.playerPrivKey
|
||||
);
|
||||
|
||||
// Verify proximity
|
||||
const proofValid = await verifyProximityProof(
|
||||
proximityProof,
|
||||
anchor.locationCommitment,
|
||||
params.playerPubKey
|
||||
);
|
||||
|
||||
if (!proofValid) {
|
||||
return { success: false, error: 'Not close enough to anchor' };
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
const existingDiscoveries = this.discoveries.get(params.anchorId) ?? [];
|
||||
const playerDiscoveries = existingDiscoveries.filter(
|
||||
(d) => d.playerPubKey === params.playerPubKey
|
||||
);
|
||||
if (playerDiscoveries.length > 0) {
|
||||
const lastDiscovery = playerDiscoveries[playerDiscoveries.length - 1];
|
||||
const timeSince = Date.now() - lastDiscovery.timestamp.getTime();
|
||||
if (timeSince < this.config.cooldownSeconds * 1000) {
|
||||
return { success: false, error: 'Discovery cooldown active' };
|
||||
}
|
||||
}
|
||||
|
||||
// Create discovery record
|
||||
const isFirstFinder = existingDiscoveries.length === 0;
|
||||
const discoveryOrder = existingDiscoveries.length + 1;
|
||||
|
||||
const discovery: Discovery = {
|
||||
id: `discovery-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
anchorId: params.anchorId,
|
||||
playerPubKey: params.playerPubKey,
|
||||
proximityProof,
|
||||
iotVerification: params.iotVerification,
|
||||
groupDiscovery: params.groupDiscovery,
|
||||
timestamp: new Date(),
|
||||
isFirstFinder,
|
||||
discoveryOrder,
|
||||
rewardsClaimed: [],
|
||||
playerSignature: await this.signDiscovery(params.playerPrivKey, params.anchorId),
|
||||
};
|
||||
|
||||
existingDiscoveries.push(discovery);
|
||||
this.discoveries.set(params.anchorId, existingDiscoveries);
|
||||
|
||||
this.emit({ type: 'anchor:discovered', discovery });
|
||||
if (isFirstFinder) {
|
||||
this.emit({ type: 'anchor:firstFind', discovery, rank: 1 });
|
||||
}
|
||||
|
||||
return { success: true, discovery };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if prerequisites are met
|
||||
*/
|
||||
private checkPrerequisites(
|
||||
anchor: DiscoveryAnchor,
|
||||
playerPubKey: string
|
||||
): { met: boolean; missing: string[] } {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const prereqId of anchor.prerequisites) {
|
||||
const prereqDiscoveries = this.discoveries.get(prereqId) ?? [];
|
||||
const hasDiscovered = prereqDiscoveries.some((d) => d.playerPubKey === playerPubKey);
|
||||
if (!hasDiscovered) {
|
||||
missing.push(prereqId);
|
||||
}
|
||||
}
|
||||
|
||||
return { met: missing.length === 0, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify IoT requirements
|
||||
*/
|
||||
private verifyIoT(
|
||||
requirements: IoTRequirement[],
|
||||
verification: Discovery['iotVerification']
|
||||
): boolean {
|
||||
if (!verification) return false;
|
||||
|
||||
for (const req of requirements) {
|
||||
if (req.type !== verification.type) continue;
|
||||
|
||||
// Check challenge response if required
|
||||
if (req.expectedResponseHash && verification.challengeResponse) {
|
||||
// In real implementation, hash the response and compare
|
||||
// For now, just check it exists
|
||||
if (!verification.challengeResponse) return false;
|
||||
}
|
||||
|
||||
// Check signal strength for BLE
|
||||
if (req.type === 'ble' && req.minRssi !== undefined) {
|
||||
if (!verification.rssi || verification.rssi < req.minRssi) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify social requirements
|
||||
*/
|
||||
private verifySocialRequirements(
|
||||
requirements: SocialRequirement,
|
||||
groupDiscovery: Discovery['groupDiscovery']
|
||||
): { valid: boolean; error?: string } {
|
||||
if (!groupDiscovery) {
|
||||
return { valid: false, error: 'Group discovery data required' };
|
||||
}
|
||||
|
||||
const playerCount = groupDiscovery.playerPubKeys.length;
|
||||
|
||||
if (playerCount < requirements.minPlayers) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Need at least ${requirements.minPlayers} players, have ${playerCount}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (requirements.maxPlayers && playerCount > requirements.maxPlayers) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Maximum ${requirements.maxPlayers} players allowed`,
|
||||
};
|
||||
}
|
||||
|
||||
if (requirements.requiredPlayers) {
|
||||
for (const required of requirements.requiredPlayers) {
|
||||
if (!groupDiscovery.playerPubKeys.includes(required)) {
|
||||
return { valid: false, error: `Required player not present: ${required}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a discovery
|
||||
*/
|
||||
private async signDiscovery(privKey: string, anchorId: string): Promise<string> {
|
||||
// In real implementation, use proper signing
|
||||
const message = `discovery:${anchorId}:${Date.now()}`;
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(message + privKey);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Navigation and Hints
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get hot/cold navigation hint
|
||||
*/
|
||||
async getNavigationHint(
|
||||
anchorId: string,
|
||||
playerLatitude: number,
|
||||
playerLongitude: number,
|
||||
playerPrecision: number = 7
|
||||
): Promise<NavigationHint | null> {
|
||||
const anchor = this.anchors.get(anchorId);
|
||||
if (!anchor) return null;
|
||||
|
||||
// Only provide hints for hinted or revealed anchors
|
||||
if (anchor.visibility === 'hidden') return null;
|
||||
|
||||
// Calculate geohash difference
|
||||
// In real implementation, compare player geohash with anchor geohash
|
||||
// For now, simulate based on precision levels
|
||||
|
||||
// Get player's geohash at various precisions
|
||||
const playerGeohash = this.latLongToGeohash(playerLatitude, playerLongitude, 12);
|
||||
const anchorGeohash = anchor.locationCommitment.geohash;
|
||||
|
||||
// Find how many characters match
|
||||
let matchingChars = 0;
|
||||
for (let i = 0; i < Math.min(playerGeohash.length, anchorGeohash.length); i++) {
|
||||
if (playerGeohash[i] === anchorGeohash[i]) {
|
||||
matchingChars++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const geohashDiff = anchor.requiredPrecision - matchingChars;
|
||||
|
||||
// Calculate temperature
|
||||
let temperature: number;
|
||||
let description: NavigationHint['description'];
|
||||
|
||||
if (geohashDiff <= 0) {
|
||||
temperature = 100;
|
||||
description = 'burning';
|
||||
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.burning.geohashDiff) {
|
||||
temperature = 90;
|
||||
description = 'burning';
|
||||
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.hot.geohashDiff) {
|
||||
temperature = 70;
|
||||
description = 'hot';
|
||||
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.warm.geohashDiff) {
|
||||
temperature = 50;
|
||||
description = 'warm';
|
||||
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.cool.geohashDiff) {
|
||||
temperature = 35;
|
||||
description = 'cool';
|
||||
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.cold.geohashDiff) {
|
||||
temperature = 20;
|
||||
description = 'cold';
|
||||
} else {
|
||||
temperature = 5;
|
||||
description = 'freezing';
|
||||
}
|
||||
|
||||
// Distance category
|
||||
let distance: NavigationHint['distance'];
|
||||
if (geohashDiff <= 0) distance = 'here';
|
||||
else if (geohashDiff <= 1) distance = 'close';
|
||||
else if (geohashDiff <= 2) distance = 'near';
|
||||
else if (geohashDiff <= 4) distance = 'medium';
|
||||
else distance = 'far';
|
||||
|
||||
return {
|
||||
anchorId,
|
||||
temperature,
|
||||
description,
|
||||
distance,
|
||||
currentPrecision: matchingChars,
|
||||
requiredPrecision: anchor.requiredPrecision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available hints for an anchor based on current conditions
|
||||
*/
|
||||
getAvailableHints(
|
||||
anchorId: string,
|
||||
playerPubKey: string,
|
||||
playerPrecision: number,
|
||||
groupSize: number = 1
|
||||
): AnchorHint[] {
|
||||
const anchor = this.anchors.get(anchorId);
|
||||
if (!anchor) return [];
|
||||
|
||||
return anchor.hints.filter((hint) => {
|
||||
return this.isHintRevealed(hint, playerPubKey, playerPrecision, groupSize);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hint should be revealed
|
||||
*/
|
||||
private isHintRevealed(
|
||||
hint: AnchorHint,
|
||||
playerPubKey: string,
|
||||
playerPrecision: number,
|
||||
groupSize: number
|
||||
): boolean {
|
||||
const condition = hint.revealCondition;
|
||||
|
||||
switch (condition.type) {
|
||||
case 'immediate':
|
||||
return true;
|
||||
|
||||
case 'proximity':
|
||||
return playerPrecision >= condition.precision;
|
||||
|
||||
case 'time':
|
||||
// Would need anchor creation time
|
||||
return true;
|
||||
|
||||
case 'discovery':
|
||||
const discoveries = this.discoveries.get(condition.anchorId) ?? [];
|
||||
return discoveries.some((d) => d.playerPubKey === playerPubKey);
|
||||
|
||||
case 'social':
|
||||
return groupSize >= condition.minPlayers;
|
||||
|
||||
case 'payment':
|
||||
// Would need payment verification
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lat/long to geohash
|
||||
* Simplified implementation - use a proper library in production
|
||||
*/
|
||||
private latLongToGeohash(lat: number, lon: number, precision: number): string {
|
||||
const base32 = '0123456789bcdefghjkmnpqrstuvwxyz';
|
||||
let minLat = -90, maxLat = 90;
|
||||
let minLon = -180, maxLon = 180;
|
||||
let hash = '';
|
||||
let bit = 0;
|
||||
let ch = 0;
|
||||
let isLon = true;
|
||||
|
||||
while (hash.length < precision) {
|
||||
if (isLon) {
|
||||
const mid = (minLon + maxLon) / 2;
|
||||
if (lon >= mid) {
|
||||
ch = ch * 2 + 1;
|
||||
minLon = mid;
|
||||
} else {
|
||||
ch = ch * 2;
|
||||
maxLon = mid;
|
||||
}
|
||||
} else {
|
||||
const mid = (minLat + maxLat) / 2;
|
||||
if (lat >= mid) {
|
||||
ch = ch * 2 + 1;
|
||||
minLat = mid;
|
||||
} else {
|
||||
ch = ch * 2;
|
||||
maxLat = mid;
|
||||
}
|
||||
}
|
||||
|
||||
isLon = !isLon;
|
||||
bit++;
|
||||
|
||||
if (bit === 5) {
|
||||
hash += base32[ch];
|
||||
bit = 0;
|
||||
ch = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Queries
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get anchor by ID
|
||||
*/
|
||||
getAnchor(id: string): DiscoveryAnchor | undefined {
|
||||
return this.anchors.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all anchors
|
||||
*/
|
||||
getAllAnchors(): DiscoveryAnchor[] {
|
||||
return Array.from(this.anchors.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get anchors by visibility
|
||||
*/
|
||||
getAnchorsByVisibility(visibility: AnchorVisibility): DiscoveryAnchor[] {
|
||||
return Array.from(this.anchors.values()).filter((a) => a.visibility === visibility);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discoveries for an anchor
|
||||
*/
|
||||
getDiscoveries(anchorId: string): Discovery[] {
|
||||
return this.discoveries.get(anchorId) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player's discoveries
|
||||
*/
|
||||
getPlayerDiscoveries(playerPubKey: string): Discovery[] {
|
||||
const all: Discovery[] = [];
|
||||
for (const discoveries of this.discoveries.values()) {
|
||||
all.push(...discoveries.filter((d) => d.playerPubKey === playerPubKey));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if player has discovered an anchor
|
||||
*/
|
||||
hasDiscovered(anchorId: string, playerPubKey: string): boolean {
|
||||
const discoveries = this.discoveries.get(anchorId) ?? [];
|
||||
return discoveries.some((d) => d.playerPubKey === playerPubKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discovery count for anchor
|
||||
*/
|
||||
getDiscoveryCount(anchorId: string): number {
|
||||
return (this.discoveries.get(anchorId) ?? []).length;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Events
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(listener: GameEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private emit(event: GameEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('Error in game event listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Serialization
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Export all anchors and discoveries
|
||||
*/
|
||||
export(): string {
|
||||
return JSON.stringify({
|
||||
anchors: Array.from(this.anchors.entries()),
|
||||
discoveries: Array.from(this.discoveries.entries()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import anchors and discoveries
|
||||
*/
|
||||
import(json: string): void {
|
||||
const data = JSON.parse(json);
|
||||
this.anchors = new Map(data.anchors);
|
||||
this.discoveries = new Map(data.discoveries);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create an anchor manager
|
||||
*/
|
||||
export function createAnchorManager(
|
||||
config?: Partial<AnchorManagerConfig>
|
||||
): AnchorManager {
|
||||
return new AnchorManager(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple reward
|
||||
*/
|
||||
export function createReward(params: {
|
||||
type: DiscoveryReward['type'];
|
||||
rewardId: string;
|
||||
quantity?: number;
|
||||
rarity?: DiscoveryReward['rarity'];
|
||||
firstFinderOnly?: number;
|
||||
dropChance?: number;
|
||||
}): DiscoveryReward {
|
||||
return {
|
||||
type: params.type,
|
||||
rewardId: params.rewardId,
|
||||
quantity: params.quantity ?? 1,
|
||||
rarity: params.rarity ?? 'common',
|
||||
firstFinderOnly: params.firstFinderOnly,
|
||||
dropChance: params.dropChance,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a text hint
|
||||
*/
|
||||
export function createTextHint(
|
||||
text: string,
|
||||
revealCondition: HintRevealCondition = { type: 'immediate' }
|
||||
): Omit<AnchorHint, 'id'> {
|
||||
return {
|
||||
revealCondition,
|
||||
content: { type: 'text', text },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hot/cold hint
|
||||
*/
|
||||
export function createHotColdHint(
|
||||
precisionLevel: number
|
||||
): Omit<AnchorHint, 'id'> {
|
||||
return {
|
||||
revealCondition: { type: 'immediate' },
|
||||
content: { type: 'hotCold', temperature: 0 }, // Temperature calculated dynamically
|
||||
precisionLevel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a riddle hint
|
||||
*/
|
||||
export function createRiddleHint(
|
||||
riddle: string,
|
||||
answer?: string,
|
||||
revealCondition: HintRevealCondition = { type: 'immediate' }
|
||||
): Omit<AnchorHint, 'id'> {
|
||||
return {
|
||||
revealCondition,
|
||||
content: { type: 'riddle', riddle, answer },
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,756 @@
|
|||
/**
|
||||
* Treasure Hunt Management System
|
||||
*
|
||||
* Organize and run treasure hunts with multiple anchors, teams,
|
||||
* scoring, and prizes. Perfect for conferences, events, and
|
||||
* collaborative discovery experiences.
|
||||
*/
|
||||
|
||||
import type {
|
||||
TreasureHunt,
|
||||
HuntScoring,
|
||||
HuntPrize,
|
||||
LeaderboardEntry,
|
||||
Discovery,
|
||||
DiscoveryAnchor,
|
||||
DiscoveryReward,
|
||||
PlayerState,
|
||||
GameEvent,
|
||||
GameEventListener,
|
||||
} from './types';
|
||||
import { AnchorManager } from './anchors';
|
||||
|
||||
// =============================================================================
|
||||
// Hunt Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for treasure hunt manager
|
||||
*/
|
||||
export interface HuntManagerConfig {
|
||||
/** Maximum anchors per hunt */
|
||||
maxAnchorsPerHunt: number;
|
||||
|
||||
/** Maximum active hunts */
|
||||
maxActiveHunts: number;
|
||||
|
||||
/** Default hunt duration in minutes */
|
||||
defaultDurationMinutes: number;
|
||||
|
||||
/** Update leaderboard every N seconds */
|
||||
leaderboardUpdateInterval: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
export const DEFAULT_HUNT_CONFIG: HuntManagerConfig = {
|
||||
maxAnchorsPerHunt: 50,
|
||||
maxActiveHunts: 10,
|
||||
defaultDurationMinutes: 120,
|
||||
leaderboardUpdateInterval: 30,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Hunt Manager
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Manages treasure hunts
|
||||
*/
|
||||
export class HuntManager {
|
||||
private config: HuntManagerConfig;
|
||||
private anchorManager: AnchorManager;
|
||||
private hunts: Map<string, TreasureHunt> = new Map();
|
||||
private playerHunts: Map<string, Set<string>> = new Map(); // playerPubKey -> huntIds
|
||||
private huntDiscoveries: Map<string, Discovery[]> = new Map(); // huntId -> discoveries
|
||||
private listeners: Set<GameEventListener> = new Set();
|
||||
private updateTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(anchorManager: AnchorManager, config: Partial<HuntManagerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_HUNT_CONFIG, ...config };
|
||||
this.anchorManager = anchorManager;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Hunt Creation
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create a new treasure hunt
|
||||
*/
|
||||
async createHunt(params: {
|
||||
name: string;
|
||||
description: string;
|
||||
creatorPubKey: string;
|
||||
anchorIds: string[];
|
||||
sequential?: boolean;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
maxDurationMinutes?: number;
|
||||
maxPlayers?: number;
|
||||
teamSize?: { min: number; max: number };
|
||||
entryFee?: { amount: number; token: string };
|
||||
inviteOnly?: boolean;
|
||||
allowedPlayers?: string[];
|
||||
scoring?: Partial<HuntScoring>;
|
||||
prizes?: HuntPrize[];
|
||||
}): Promise<{ success: boolean; hunt?: TreasureHunt; error?: string }> {
|
||||
// Validate anchors
|
||||
if (params.anchorIds.length > this.config.maxAnchorsPerHunt) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Maximum ${this.config.maxAnchorsPerHunt} anchors per hunt`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const anchorId of params.anchorIds) {
|
||||
const anchor = this.anchorManager.getAnchor(anchorId);
|
||||
if (!anchor) {
|
||||
return { success: false, error: `Anchor not found: ${anchorId}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Check active hunt limit
|
||||
const activeHunts = Array.from(this.hunts.values()).filter(
|
||||
(h) => h.state === 'active' || h.state === 'upcoming'
|
||||
);
|
||||
if (activeHunts.length >= this.config.maxActiveHunts) {
|
||||
return { success: false, error: 'Maximum active hunts reached' };
|
||||
}
|
||||
|
||||
const id = `hunt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const scoring: HuntScoring = {
|
||||
pointsPerDiscovery: params.scoring?.pointsPerDiscovery ?? 100,
|
||||
firstFinderBonus: params.scoring?.firstFinderBonus ?? 50,
|
||||
timeBonus: params.scoring?.timeBonus,
|
||||
sequenceBonus: params.scoring?.sequenceBonus,
|
||||
groupBonus: params.scoring?.groupBonus,
|
||||
rarityMultiplier: params.scoring?.rarityMultiplier ?? {},
|
||||
};
|
||||
|
||||
const hunt: TreasureHunt = {
|
||||
id,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
creatorPubKey: params.creatorPubKey,
|
||||
anchorIds: params.anchorIds,
|
||||
sequential: params.sequential ?? false,
|
||||
timing: {
|
||||
startsAt: params.startsAt,
|
||||
endsAt: params.endsAt,
|
||||
maxDurationMinutes: params.maxDurationMinutes,
|
||||
},
|
||||
participation: {
|
||||
maxPlayers: params.maxPlayers,
|
||||
teamSize: params.teamSize,
|
||||
entryFee: params.entryFee,
|
||||
inviteOnly: params.inviteOnly ?? false,
|
||||
allowedPlayers: params.allowedPlayers,
|
||||
},
|
||||
scoring,
|
||||
prizes: params.prizes ?? [],
|
||||
state: 'upcoming',
|
||||
leaderboard: [],
|
||||
};
|
||||
|
||||
this.hunts.set(id, hunt);
|
||||
this.huntDiscoveries.set(id, []);
|
||||
|
||||
this.emit({ type: 'hunt:started', hunt });
|
||||
|
||||
return { success: true, hunt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a prize to a hunt
|
||||
*/
|
||||
addPrize(huntId: string, prize: HuntPrize): boolean {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt || hunt.state !== 'upcoming') return false;
|
||||
|
||||
hunt.prizes.push(prize);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an anchor to a hunt
|
||||
*/
|
||||
addAnchor(huntId: string, anchorId: string): boolean {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt || hunt.state !== 'upcoming') return false;
|
||||
|
||||
if (hunt.anchorIds.length >= this.config.maxAnchorsPerHunt) return false;
|
||||
|
||||
const anchor = this.anchorManager.getAnchor(anchorId);
|
||||
if (!anchor) return false;
|
||||
|
||||
hunt.anchorIds.push(anchorId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Hunt Participation
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Join a hunt
|
||||
*/
|
||||
joinHunt(
|
||||
huntId: string,
|
||||
playerPubKey: string
|
||||
): { success: boolean; error?: string } {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt) {
|
||||
return { success: false, error: 'Hunt not found' };
|
||||
}
|
||||
|
||||
if (hunt.state !== 'upcoming' && hunt.state !== 'active') {
|
||||
return { success: false, error: 'Hunt not accepting participants' };
|
||||
}
|
||||
|
||||
// Check invite-only
|
||||
if (hunt.participation.inviteOnly) {
|
||||
if (
|
||||
!hunt.participation.allowedPlayers?.includes(playerPubKey) &&
|
||||
hunt.creatorPubKey !== playerPubKey
|
||||
) {
|
||||
return { success: false, error: 'Hunt is invite-only' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check max players
|
||||
const currentPlayers = this.getHuntParticipants(huntId);
|
||||
if (
|
||||
hunt.participation.maxPlayers &&
|
||||
currentPlayers.length >= hunt.participation.maxPlayers
|
||||
) {
|
||||
return { success: false, error: 'Hunt is full' };
|
||||
}
|
||||
|
||||
// Add player to hunt
|
||||
const playerHunts = this.playerHunts.get(playerPubKey) ?? new Set();
|
||||
playerHunts.add(huntId);
|
||||
this.playerHunts.set(playerPubKey, playerHunts);
|
||||
|
||||
// Initialize leaderboard entry
|
||||
if (!hunt.leaderboard.find((e) => e.playerId === playerPubKey)) {
|
||||
hunt.leaderboard.push({
|
||||
playerId: playerPubKey,
|
||||
displayName: playerPubKey.slice(0, 8) + '...',
|
||||
score: 0,
|
||||
discoveriesCount: 0,
|
||||
firstFindsCount: 0,
|
||||
rank: hunt.leaderboard.length + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a hunt
|
||||
*/
|
||||
leaveHunt(huntId: string, playerPubKey: string): boolean {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt) return false;
|
||||
|
||||
const playerHunts = this.playerHunts.get(playerPubKey);
|
||||
if (playerHunts) {
|
||||
playerHunts.delete(huntId);
|
||||
}
|
||||
|
||||
// Remove from leaderboard
|
||||
hunt.leaderboard = hunt.leaderboard.filter((e) => e.playerId !== playerPubKey);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all participants in a hunt
|
||||
*/
|
||||
getHuntParticipants(huntId: string): string[] {
|
||||
const participants: string[] = [];
|
||||
for (const [playerId, hunts] of this.playerHunts.entries()) {
|
||||
if (hunts.has(huntId)) {
|
||||
participants.push(playerId);
|
||||
}
|
||||
}
|
||||
return participants;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Discovery Recording
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Record a discovery for a hunt
|
||||
*/
|
||||
recordDiscovery(
|
||||
huntId: string,
|
||||
discovery: Discovery
|
||||
): { success: boolean; pointsAwarded: number; error?: string } {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt) {
|
||||
return { success: false, pointsAwarded: 0, error: 'Hunt not found' };
|
||||
}
|
||||
|
||||
if (hunt.state !== 'active') {
|
||||
return { success: false, pointsAwarded: 0, error: 'Hunt not active' };
|
||||
}
|
||||
|
||||
// Check if anchor is part of hunt
|
||||
if (!hunt.anchorIds.includes(discovery.anchorId)) {
|
||||
return { success: false, pointsAwarded: 0, error: 'Anchor not in hunt' };
|
||||
}
|
||||
|
||||
// Check sequential order
|
||||
if (hunt.sequential) {
|
||||
const playerDiscoveries = this.getPlayerHuntDiscoveries(
|
||||
huntId,
|
||||
discovery.playerPubKey
|
||||
);
|
||||
const expectedAnchorIndex = playerDiscoveries.length;
|
||||
const actualAnchorIndex = hunt.anchorIds.indexOf(discovery.anchorId);
|
||||
|
||||
if (actualAnchorIndex !== expectedAnchorIndex) {
|
||||
return {
|
||||
success: false,
|
||||
pointsAwarded: 0,
|
||||
error: 'Must discover anchors in sequence',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Record discovery
|
||||
const discoveries = this.huntDiscoveries.get(huntId) ?? [];
|
||||
discoveries.push(discovery);
|
||||
this.huntDiscoveries.set(huntId, discoveries);
|
||||
|
||||
// Calculate points
|
||||
let points = hunt.scoring.pointsPerDiscovery;
|
||||
|
||||
// First finder bonus
|
||||
if (discovery.isFirstFinder) {
|
||||
points += hunt.scoring.firstFinderBonus;
|
||||
}
|
||||
|
||||
// Sequence bonus
|
||||
if (hunt.sequential && hunt.scoring.sequenceBonus) {
|
||||
points += hunt.scoring.sequenceBonus;
|
||||
}
|
||||
|
||||
// Update leaderboard
|
||||
this.updatePlayerScore(huntId, discovery.playerPubKey, points, discovery.isFirstFinder);
|
||||
|
||||
return { success: true, pointsAwarded: points };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player's discoveries in a hunt
|
||||
*/
|
||||
getPlayerHuntDiscoveries(huntId: string, playerPubKey: string): Discovery[] {
|
||||
const discoveries = this.huntDiscoveries.get(huntId) ?? [];
|
||||
return discoveries.filter((d) => d.playerPubKey === playerPubKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a player's score
|
||||
*/
|
||||
private updatePlayerScore(
|
||||
huntId: string,
|
||||
playerPubKey: string,
|
||||
pointsToAdd: number,
|
||||
isFirstFind: boolean
|
||||
): void {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt) return;
|
||||
|
||||
const entry = hunt.leaderboard.find((e) => e.playerId === playerPubKey);
|
||||
if (entry) {
|
||||
entry.score += pointsToAdd;
|
||||
entry.discoveriesCount++;
|
||||
if (isFirstFind) {
|
||||
entry.firstFindsCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateLeaderboardRanks(huntId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update leaderboard rankings
|
||||
*/
|
||||
private updateLeaderboardRanks(huntId: string): void {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt) return;
|
||||
|
||||
// Sort by score descending
|
||||
hunt.leaderboard.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Update ranks
|
||||
for (let i = 0; i < hunt.leaderboard.length; i++) {
|
||||
hunt.leaderboard[i].rank = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Hunt Lifecycle
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Start hunt lifecycle management
|
||||
*/
|
||||
startLifecycleManager(): void {
|
||||
if (this.updateTimer) return;
|
||||
|
||||
this.updateTimer = setInterval(() => {
|
||||
this.updateHuntStates();
|
||||
}, this.config.leaderboardUpdateInterval * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop lifecycle manager
|
||||
*/
|
||||
stopLifecycleManager(): void {
|
||||
if (this.updateTimer) {
|
||||
clearInterval(this.updateTimer);
|
||||
this.updateTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hunt states based on time
|
||||
*/
|
||||
private updateHuntStates(): void {
|
||||
const now = new Date();
|
||||
|
||||
for (const hunt of this.hunts.values()) {
|
||||
// Start upcoming hunts
|
||||
if (hunt.state === 'upcoming' && now >= hunt.timing.startsAt) {
|
||||
hunt.state = 'active';
|
||||
this.emit({ type: 'hunt:started', hunt });
|
||||
}
|
||||
|
||||
// End active hunts
|
||||
if (hunt.state === 'active' && now >= hunt.timing.endsAt) {
|
||||
this.completeHunt(hunt.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually start a hunt
|
||||
*/
|
||||
startHunt(huntId: string): boolean {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt || hunt.state !== 'upcoming') return false;
|
||||
|
||||
hunt.state = 'active';
|
||||
hunt.timing.startsAt = new Date();
|
||||
|
||||
this.emit({ type: 'hunt:started', hunt });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a hunt and determine winners
|
||||
*/
|
||||
completeHunt(huntId: string): {
|
||||
success: boolean;
|
||||
winners?: Array<{ playerId: string; position: number; prize: HuntPrize }>;
|
||||
} {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
hunt.state = 'completed';
|
||||
|
||||
// Determine winners
|
||||
const winners: Array<{ playerId: string; position: number; prize: HuntPrize }> = [];
|
||||
|
||||
for (const prize of hunt.prizes) {
|
||||
const entry = hunt.leaderboard[prize.position - 1];
|
||||
if (entry) {
|
||||
winners.push({
|
||||
playerId: entry.playerId,
|
||||
position: prize.position,
|
||||
prize,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const winnerId = hunt.leaderboard[0]?.playerId ?? '';
|
||||
this.emit({ type: 'hunt:completed', hunt, winnerId });
|
||||
|
||||
return { success: true, winners };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a hunt
|
||||
*/
|
||||
cancelHunt(huntId: string): boolean {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt) return false;
|
||||
|
||||
hunt.state = 'cancelled';
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Queries
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get hunt by ID
|
||||
*/
|
||||
getHunt(id: string): TreasureHunt | undefined {
|
||||
return this.hunts.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hunts
|
||||
*/
|
||||
getAllHunts(): TreasureHunt[] {
|
||||
return Array.from(this.hunts.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active hunts
|
||||
*/
|
||||
getActiveHunts(): TreasureHunt[] {
|
||||
return Array.from(this.hunts.values()).filter((h) => h.state === 'active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming hunts
|
||||
*/
|
||||
getUpcomingHunts(): TreasureHunt[] {
|
||||
return Array.from(this.hunts.values()).filter((h) => h.state === 'upcoming');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player's active hunts
|
||||
*/
|
||||
getPlayerHunts(playerPubKey: string): TreasureHunt[] {
|
||||
const huntIds = this.playerHunts.get(playerPubKey) ?? new Set();
|
||||
return Array.from(huntIds)
|
||||
.map((id) => this.hunts.get(id))
|
||||
.filter((h): h is TreasureHunt => h !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hunt leaderboard
|
||||
*/
|
||||
getLeaderboard(huntId: string): LeaderboardEntry[] {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
return hunt?.leaderboard ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player's rank in a hunt
|
||||
*/
|
||||
getPlayerRank(huntId: string, playerPubKey: string): number | null {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt) return null;
|
||||
|
||||
const entry = hunt.leaderboard.find((e) => e.playerId === playerPubKey);
|
||||
return entry?.rank ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hunt progress for a player
|
||||
*/
|
||||
getPlayerProgress(
|
||||
huntId: string,
|
||||
playerPubKey: string
|
||||
): {
|
||||
discovered: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
nextAnchor?: string;
|
||||
} | null {
|
||||
const hunt = this.hunts.get(huntId);
|
||||
if (!hunt) return null;
|
||||
|
||||
const discoveries = this.getPlayerHuntDiscoveries(huntId, playerPubKey);
|
||||
const discoveredAnchorIds = new Set(discoveries.map((d) => d.anchorId));
|
||||
|
||||
const discovered = discoveredAnchorIds.size;
|
||||
const total = hunt.anchorIds.length;
|
||||
const percentage = total > 0 ? (discovered / total) * 100 : 0;
|
||||
|
||||
let nextAnchor: string | undefined;
|
||||
if (hunt.sequential && discovered < total) {
|
||||
nextAnchor = hunt.anchorIds[discovered];
|
||||
} else {
|
||||
// Find first undiscovered anchor
|
||||
nextAnchor = hunt.anchorIds.find((id) => !discoveredAnchorIds.has(id));
|
||||
}
|
||||
|
||||
return { discovered, total, percentage, nextAnchor };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Events
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(listener: GameEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private emit(event: GameEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('Error in game event listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Serialization
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Export state
|
||||
*/
|
||||
export(): string {
|
||||
return JSON.stringify({
|
||||
hunts: Array.from(this.hunts.entries()),
|
||||
playerHunts: Array.from(this.playerHunts.entries()).map(([k, v]) => [
|
||||
k,
|
||||
Array.from(v),
|
||||
]),
|
||||
huntDiscoveries: Array.from(this.huntDiscoveries.entries()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import state
|
||||
*/
|
||||
import(json: string): void {
|
||||
const data = JSON.parse(json);
|
||||
this.hunts = new Map(data.hunts);
|
||||
this.playerHunts = new Map(
|
||||
data.playerHunts.map(([k, v]: [string, string[]]) => [k, new Set(v)])
|
||||
);
|
||||
this.huntDiscoveries = new Map(data.huntDiscoveries);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a hunt manager
|
||||
*/
|
||||
export function createHuntManager(
|
||||
anchorManager: AnchorManager,
|
||||
config?: Partial<HuntManagerConfig>
|
||||
): HuntManager {
|
||||
return new HuntManager(anchorManager, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple scoring configuration
|
||||
*/
|
||||
export function createScoring(params: {
|
||||
pointsPerDiscovery?: number;
|
||||
firstFinderBonus?: number;
|
||||
timeBonus?: number;
|
||||
sequenceBonus?: number;
|
||||
groupBonus?: number;
|
||||
}): HuntScoring {
|
||||
return {
|
||||
pointsPerDiscovery: params.pointsPerDiscovery ?? 100,
|
||||
firstFinderBonus: params.firstFinderBonus ?? 50,
|
||||
timeBonus: params.timeBonus,
|
||||
sequenceBonus: params.sequenceBonus,
|
||||
groupBonus: params.groupBonus,
|
||||
rarityMultiplier: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a prize
|
||||
*/
|
||||
export function createPrize(params: {
|
||||
position: number;
|
||||
description: string;
|
||||
rewards: DiscoveryReward[];
|
||||
}): HuntPrize {
|
||||
return {
|
||||
position: params.position,
|
||||
description: params.description,
|
||||
rewards: params.rewards,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hunt Templates
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Template for a quick hunt (30 minutes, few anchors)
|
||||
*/
|
||||
export const QUICK_HUNT_TEMPLATE = {
|
||||
duration: 30,
|
||||
maxAnchors: 5,
|
||||
scoring: {
|
||||
pointsPerDiscovery: 100,
|
||||
firstFinderBonus: 50,
|
||||
timeBonus: 10, // Points per minute under par
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Template for a standard hunt (2 hours)
|
||||
*/
|
||||
export const STANDARD_HUNT_TEMPLATE = {
|
||||
duration: 120,
|
||||
maxAnchors: 15,
|
||||
scoring: {
|
||||
pointsPerDiscovery: 100,
|
||||
firstFinderBonus: 100,
|
||||
timeBonus: 5,
|
||||
sequenceBonus: 25,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Template for an epic hunt (all day event)
|
||||
*/
|
||||
export const EPIC_HUNT_TEMPLATE = {
|
||||
duration: 480, // 8 hours
|
||||
maxAnchors: 50,
|
||||
scoring: {
|
||||
pointsPerDiscovery: 100,
|
||||
firstFinderBonus: 200,
|
||||
timeBonus: 2,
|
||||
sequenceBonus: 50,
|
||||
groupBonus: 100,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Template for a collaborative hunt (team-based)
|
||||
*/
|
||||
export const TEAM_HUNT_TEMPLATE = {
|
||||
duration: 180,
|
||||
maxAnchors: 20,
|
||||
teamSize: { min: 2, max: 5 },
|
||||
scoring: {
|
||||
pointsPerDiscovery: 150,
|
||||
firstFinderBonus: 75,
|
||||
groupBonus: 200,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* zkGPS Location Games and Discovery System
|
||||
*
|
||||
* A framework for privacy-preserving location-based games, treasure hunts,
|
||||
* and collaborative discovery experiences. Uses zkGPS proofs to verify
|
||||
* proximity without revealing exact locations.
|
||||
*
|
||||
* Key Features:
|
||||
* - Privacy-preserving location verification via zkGPS
|
||||
* - Hot/cold navigation hints without revealing target
|
||||
* - Collectible items with crafting system
|
||||
* - Mycelium-inspired spore planting and network growth
|
||||
* - Fruiting bodies that emerge when networks connect
|
||||
* - Organized treasure hunts with scoring and prizes
|
||||
* - IoT hardware integration (NFC, BLE, QR)
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import {
|
||||
* createAnchorManager,
|
||||
* createItemRegistry,
|
||||
* createInventoryManager,
|
||||
* createSporeManager,
|
||||
* createHuntManager,
|
||||
* } from './discovery';
|
||||
*
|
||||
* // Initialize systems
|
||||
* const anchors = createAnchorManager();
|
||||
* const items = createItemRegistry();
|
||||
* const inventory = createInventoryManager(items);
|
||||
* const spores = createSporeManager();
|
||||
* const hunts = createHuntManager(anchors);
|
||||
*
|
||||
* // Create a hidden anchor
|
||||
* const anchor = await anchors.createAnchor({
|
||||
* name: 'Secret Garden',
|
||||
* description: 'A hidden oasis in the city',
|
||||
* type: 'physical',
|
||||
* visibility: 'hinted',
|
||||
* latitude: 51.5074,
|
||||
* longitude: -0.1278,
|
||||
* creatorPubKey: myPublicKey,
|
||||
* creatorPrivKey: myPrivateKey,
|
||||
* rewards: [
|
||||
* { type: 'spore', rewardId: 'spore-explorer', quantity: 3, rarity: 'common' },
|
||||
* ],
|
||||
* });
|
||||
*
|
||||
* // Get hot/cold hint for player
|
||||
* const hint = await anchors.getNavigationHint(
|
||||
* anchor.id,
|
||||
* playerLat,
|
||||
* playerLon
|
||||
* );
|
||||
* // hint.description = 'warm' | 'hot' | 'burning' etc.
|
||||
*
|
||||
* // Attempt discovery
|
||||
* const result = await anchors.attemptDiscovery({
|
||||
* anchorId: anchor.id,
|
||||
* playerPubKey: playerKey,
|
||||
* playerPrivKey: playerPriv,
|
||||
* playerLatitude: playerLat,
|
||||
* playerLongitude: playerLon,
|
||||
* });
|
||||
*
|
||||
* if (result.success) {
|
||||
* // Claim rewards, update inventory, etc.
|
||||
* }
|
||||
*
|
||||
* // Plant spores at discovered location
|
||||
* const spore = spores.createSpore('explorer');
|
||||
* await spores.plantSpore({
|
||||
* spore,
|
||||
* locationCommitment: anchor.locationCommitment,
|
||||
* planterPubKey: playerKey,
|
||||
* });
|
||||
*
|
||||
* // Create a treasure hunt
|
||||
* const hunt = await hunts.createHunt({
|
||||
* name: 'Conference Scavenger Hunt',
|
||||
* description: 'Find all the hidden spots!',
|
||||
* creatorPubKey: organizerKey,
|
||||
* anchorIds: [anchor1.id, anchor2.id, anchor3.id],
|
||||
* startsAt: new Date(),
|
||||
* endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000), // 2 hours
|
||||
* prizes: [
|
||||
* createPrize({ position: 1, description: '1st Place', rewards: [...] }),
|
||||
* ],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Core types
|
||||
export type {
|
||||
// Anchors
|
||||
AnchorType,
|
||||
AnchorVisibility,
|
||||
DiscoveryAnchor,
|
||||
IoTRequirement,
|
||||
SocialRequirement,
|
||||
AnchorHint,
|
||||
HintRevealCondition,
|
||||
HintContent,
|
||||
|
||||
// Discoveries
|
||||
Discovery,
|
||||
IoTVerification,
|
||||
GroupDiscovery,
|
||||
|
||||
// Rewards
|
||||
RewardType,
|
||||
DiscoveryReward,
|
||||
RewardCondition,
|
||||
ClaimedReward,
|
||||
|
||||
// Collectibles
|
||||
CollectibleCategory,
|
||||
Collectible,
|
||||
ItemAbility,
|
||||
ItemEffect,
|
||||
CraftingRecipe,
|
||||
CraftingIngredient,
|
||||
CraftingOutput,
|
||||
InventorySlot,
|
||||
|
||||
// Spores and Mycelium
|
||||
SporeType,
|
||||
Spore,
|
||||
PlantedSpore,
|
||||
FruitingBody,
|
||||
FruitingBodyType,
|
||||
|
||||
// Treasure Hunts
|
||||
TreasureHunt,
|
||||
HuntScoring,
|
||||
HuntPrize,
|
||||
LeaderboardEntry,
|
||||
|
||||
// Player
|
||||
PlayerState,
|
||||
PlayerStats,
|
||||
PlayerPreferences,
|
||||
|
||||
// Navigation
|
||||
NavigationHint,
|
||||
|
||||
// Events
|
||||
GameEvent,
|
||||
GameEventListener,
|
||||
} from './types';
|
||||
|
||||
export { TEMPERATURE_THRESHOLDS } from './types';
|
||||
|
||||
// Anchor management
|
||||
export {
|
||||
AnchorManager,
|
||||
createAnchorManager,
|
||||
createReward,
|
||||
createTextHint,
|
||||
createHotColdHint,
|
||||
createRiddleHint,
|
||||
DEFAULT_ANCHOR_CONFIG,
|
||||
type AnchorManagerConfig,
|
||||
} from './anchors';
|
||||
|
||||
// Collectibles and crafting
|
||||
export {
|
||||
ItemRegistry,
|
||||
InventoryManager,
|
||||
CraftingManager,
|
||||
createItemRegistry,
|
||||
createInventoryManager,
|
||||
createCraftingManager,
|
||||
createCollectible,
|
||||
createRecipe,
|
||||
DEFAULT_SPORE_ITEMS,
|
||||
DEFAULT_FRAGMENT_ITEMS,
|
||||
DEFAULT_ARTIFACT_ITEMS,
|
||||
DEFAULT_RECIPES,
|
||||
DEFAULT_INVENTORY_CONFIG,
|
||||
type InventoryConfig,
|
||||
type CraftingJob,
|
||||
} from './collectibles';
|
||||
|
||||
// Spore and mycelium integration
|
||||
export {
|
||||
SporeManager,
|
||||
createSporeManager,
|
||||
createSporeFromType,
|
||||
SPORE_TEMPLATES,
|
||||
DEFAULT_SPORE_CONFIG,
|
||||
type SporeSystemConfig,
|
||||
} from './spores';
|
||||
|
||||
// Treasure hunts
|
||||
export {
|
||||
HuntManager,
|
||||
createHuntManager,
|
||||
createScoring,
|
||||
createPrize,
|
||||
QUICK_HUNT_TEMPLATE,
|
||||
STANDARD_HUNT_TEMPLATE,
|
||||
EPIC_HUNT_TEMPLATE,
|
||||
TEAM_HUNT_TEMPLATE,
|
||||
DEFAULT_HUNT_CONFIG,
|
||||
type HuntManagerConfig,
|
||||
} from './hunts';
|
||||
|
|
@ -0,0 +1,832 @@
|
|||
/**
|
||||
* Spore and Mycelium Growth System
|
||||
*
|
||||
* Integrates the mycelium network with the discovery game system.
|
||||
* Players can plant spores at discovered locations, growing networks
|
||||
* that produce fruiting bodies when they connect.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Spore,
|
||||
SporeType,
|
||||
PlantedSpore,
|
||||
FruitingBody,
|
||||
FruitingBodyType,
|
||||
DiscoveryReward,
|
||||
GameEvent,
|
||||
GameEventListener,
|
||||
} from './types';
|
||||
import type { GeohashCommitment } from '../privacy/types';
|
||||
import type { MyceliumNode, Hypha, Signal, NodeType, HyphaType } from '../mycelium/types';
|
||||
import { MyceliumNetwork, createMyceliumNetwork } from '../mycelium';
|
||||
|
||||
// =============================================================================
|
||||
// Spore Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for the spore system
|
||||
*/
|
||||
export interface SporeSystemConfig {
|
||||
/** Base growth rate (units per tick) */
|
||||
baseGrowthRate: number;
|
||||
|
||||
/** Nutrient decay rate per tick */
|
||||
nutrientDecayRate: number;
|
||||
|
||||
/** Distance threshold for spore connection */
|
||||
connectionDistance: number;
|
||||
|
||||
/** Minimum network nodes to spawn fruiting body */
|
||||
minNodesForFruit: number;
|
||||
|
||||
/** Fruiting body spawn chance when conditions met */
|
||||
fruitSpawnChance: number;
|
||||
|
||||
/** Maximum active spores per player */
|
||||
maxSporesPerPlayer: number;
|
||||
|
||||
/** Tick interval in milliseconds */
|
||||
tickInterval: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
export const DEFAULT_SPORE_CONFIG: SporeSystemConfig = {
|
||||
baseGrowthRate: 1,
|
||||
nutrientDecayRate: 0.1,
|
||||
connectionDistance: 100, // meters
|
||||
minNodesForFruit: 3,
|
||||
fruitSpawnChance: 0.3,
|
||||
maxSporesPerPlayer: 10,
|
||||
tickInterval: 60000, // 1 minute
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Spore Templates
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Pre-defined spore templates
|
||||
*/
|
||||
export const SPORE_TEMPLATES: Record<SporeType, Omit<Spore, 'id'>> = {
|
||||
explorer: {
|
||||
type: 'explorer',
|
||||
growthRate: 1.5,
|
||||
maxReach: 150,
|
||||
nutrientCapacity: 100,
|
||||
properties: {
|
||||
revealRadius: 50,
|
||||
speedBoost: 1.2,
|
||||
},
|
||||
visual: {
|
||||
color: '#4ade80',
|
||||
pattern: 'radial',
|
||||
},
|
||||
},
|
||||
connector: {
|
||||
type: 'connector',
|
||||
growthRate: 0.8,
|
||||
maxReach: 300,
|
||||
nutrientCapacity: 150,
|
||||
properties: {
|
||||
connectionStrength: 2,
|
||||
signalBoost: 1.5,
|
||||
},
|
||||
visual: {
|
||||
color: '#818cf8',
|
||||
pattern: 'branching',
|
||||
},
|
||||
},
|
||||
amplifier: {
|
||||
type: 'amplifier',
|
||||
growthRate: 0.5,
|
||||
maxReach: 50,
|
||||
nutrientCapacity: 200,
|
||||
properties: {
|
||||
signalAmplification: 3,
|
||||
rangeBoost: 2,
|
||||
},
|
||||
visual: {
|
||||
color: '#fbbf24',
|
||||
pattern: 'spiral',
|
||||
},
|
||||
},
|
||||
guardian: {
|
||||
type: 'guardian',
|
||||
growthRate: 0.3,
|
||||
maxReach: 75,
|
||||
nutrientCapacity: 300,
|
||||
properties: {
|
||||
protectionRadius: 100,
|
||||
decayResistance: 5,
|
||||
},
|
||||
visual: {
|
||||
color: '#f472b6',
|
||||
pattern: 'clustered',
|
||||
},
|
||||
},
|
||||
harvester: {
|
||||
type: 'harvester',
|
||||
growthRate: 0.6,
|
||||
maxReach: 100,
|
||||
nutrientCapacity: 120,
|
||||
properties: {
|
||||
yieldMultiplier: 2,
|
||||
harvestSpeed: 1.5,
|
||||
},
|
||||
visual: {
|
||||
color: '#a78bfa',
|
||||
pattern: 'branching',
|
||||
},
|
||||
},
|
||||
temporal: {
|
||||
type: 'temporal',
|
||||
growthRate: 1.0,
|
||||
maxReach: 80,
|
||||
nutrientCapacity: 80,
|
||||
properties: {
|
||||
timeShift: 30, // minutes
|
||||
phaseChance: 0.1,
|
||||
},
|
||||
visual: {
|
||||
color: '#67e8f9',
|
||||
pattern: 'spiral',
|
||||
},
|
||||
},
|
||||
social: {
|
||||
type: 'social',
|
||||
growthRate: 0.7,
|
||||
maxReach: 200,
|
||||
nutrientCapacity: 100,
|
||||
properties: {
|
||||
groupBonus: 1.5,
|
||||
connectionRange: 50,
|
||||
},
|
||||
visual: {
|
||||
color: '#fb923c',
|
||||
pattern: 'radial',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Spore Manager
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Manages spore planting and mycelium growth
|
||||
*/
|
||||
export class SporeManager {
|
||||
private config: SporeSystemConfig;
|
||||
private network: MyceliumNetwork;
|
||||
private plantedSpores: Map<string, PlantedSpore> = new Map();
|
||||
private fruitingBodies: Map<string, FruitingBody> = new Map();
|
||||
private playerSporeCount: Map<string, number> = new Map();
|
||||
private listeners: Set<GameEventListener> = new Set();
|
||||
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(config: Partial<SporeSystemConfig> = {}) {
|
||||
this.config = { ...DEFAULT_SPORE_CONFIG, ...config };
|
||||
this.network = createMyceliumNetwork();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Spore Planting
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create a spore from template
|
||||
*/
|
||||
createSpore(type: SporeType): Spore {
|
||||
const template = SPORE_TEMPLATES[type];
|
||||
return {
|
||||
...template,
|
||||
id: `spore-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plant a spore at a location
|
||||
*/
|
||||
async plantSpore(params: {
|
||||
spore: Spore;
|
||||
locationCommitment: GeohashCommitment;
|
||||
planterPubKey: string;
|
||||
}): Promise<{ success: boolean; planted?: PlantedSpore; error?: string }> {
|
||||
// Check player spore limit
|
||||
const currentCount = this.playerSporeCount.get(params.planterPubKey) ?? 0;
|
||||
if (currentCount >= this.config.maxSporesPerPlayer) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Maximum ${this.config.maxSporesPerPlayer} active spores allowed`,
|
||||
};
|
||||
}
|
||||
|
||||
// Create mycelium node at location
|
||||
const node = this.network.addNode({
|
||||
type: this.sporeTypeToNodeType(params.spore.type),
|
||||
position: this.geohashToPosition(params.locationCommitment.geohash),
|
||||
strength: params.spore.nutrientCapacity / 100,
|
||||
data: {
|
||||
sporeId: params.spore.id,
|
||||
planterPubKey: params.planterPubKey,
|
||||
sporeType: params.spore.type,
|
||||
},
|
||||
});
|
||||
|
||||
// Create planted spore record
|
||||
const planted: PlantedSpore = {
|
||||
id: `planted-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
spore: params.spore,
|
||||
locationCommitment: params.locationCommitment,
|
||||
planterPubKey: params.planterPubKey,
|
||||
plantedAt: new Date(),
|
||||
nutrients: params.spore.nutrientCapacity,
|
||||
nodeId: node.id,
|
||||
hyphaIds: [],
|
||||
};
|
||||
|
||||
this.plantedSpores.set(planted.id, planted);
|
||||
this.playerSporeCount.set(params.planterPubKey, currentCount + 1);
|
||||
|
||||
this.emit({ type: 'spore:planted', spore: planted });
|
||||
|
||||
// Check for nearby spores to connect
|
||||
this.attemptConnections(planted);
|
||||
|
||||
return { success: true, planted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to connect a newly planted spore with nearby ones
|
||||
*/
|
||||
private attemptConnections(planted: PlantedSpore): void {
|
||||
const plantedPosition = this.geohashToPosition(planted.locationCommitment.geohash);
|
||||
|
||||
for (const [id, other] of this.plantedSpores.entries()) {
|
||||
if (id === planted.id) continue;
|
||||
if (other.nutrients <= 0) continue;
|
||||
|
||||
const otherPosition = this.geohashToPosition(other.locationCommitment.geohash);
|
||||
const distance = this.calculateDistance(plantedPosition, otherPosition);
|
||||
|
||||
// Check if within connection range
|
||||
const maxRange = Math.min(planted.spore.maxReach, other.spore.maxReach);
|
||||
if (distance <= maxRange) {
|
||||
// Create hypha connection
|
||||
const hypha = this.network.addHypha({
|
||||
type: this.getHyphaType(planted.spore.type, other.spore.type),
|
||||
fromId: planted.nodeId,
|
||||
toId: other.nodeId,
|
||||
strength: 0.5,
|
||||
data: {
|
||||
plantedSporeIds: [planted.id, other.id],
|
||||
},
|
||||
});
|
||||
|
||||
planted.hyphaIds.push(hypha.id);
|
||||
other.hyphaIds.push(hypha.id);
|
||||
|
||||
// Check for fruiting body conditions
|
||||
this.checkFruitingConditions(planted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map spore type to mycelium node type
|
||||
*/
|
||||
private sporeTypeToNodeType(sporeType: SporeType): NodeType {
|
||||
const mapping: Record<SporeType, NodeType> = {
|
||||
explorer: 'discovery',
|
||||
connector: 'waypoint',
|
||||
amplifier: 'poi',
|
||||
guardian: 'cluster',
|
||||
harvester: 'resource',
|
||||
temporal: 'event',
|
||||
social: 'person',
|
||||
};
|
||||
return mapping[sporeType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hypha type based on connected spore types
|
||||
*/
|
||||
private getHyphaType(type1: SporeType, type2: SporeType): HyphaType {
|
||||
if (type1 === 'social' || type2 === 'social') return 'social';
|
||||
if (type1 === 'temporal' || type2 === 'temporal') return 'temporal';
|
||||
if (type1 === 'connector' || type2 === 'connector') return 'route';
|
||||
return 'proximity';
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Fruiting Bodies
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Check if conditions are met for a fruiting body
|
||||
*/
|
||||
private checkFruitingConditions(spore: PlantedSpore): void {
|
||||
// Find all connected spores
|
||||
const connected = this.findConnectedSpores(spore.id);
|
||||
|
||||
if (connected.length >= this.config.minNodesForFruit) {
|
||||
// Random chance to spawn
|
||||
if (Math.random() < this.config.fruitSpawnChance) {
|
||||
this.spawnFruitingBody(connected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all spores connected to a given spore
|
||||
*/
|
||||
private findConnectedSpores(sporeId: string): PlantedSpore[] {
|
||||
const connected: PlantedSpore[] = [];
|
||||
const visited = new Set<string>();
|
||||
const queue = [sporeId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!;
|
||||
if (visited.has(currentId)) continue;
|
||||
visited.add(currentId);
|
||||
|
||||
const spore = this.plantedSpores.get(currentId);
|
||||
if (!spore || spore.nutrients <= 0) continue;
|
||||
|
||||
connected.push(spore);
|
||||
|
||||
// Find connections via hyphae
|
||||
for (const hyphaId of spore.hyphaIds) {
|
||||
const hypha = this.network.getHypha(hyphaId);
|
||||
if (hypha) {
|
||||
// Find the other node
|
||||
const otherNodeId =
|
||||
hypha.fromId === spore.nodeId ? hypha.toId : hypha.fromId;
|
||||
|
||||
// Find spore by node ID
|
||||
for (const [id, s] of this.plantedSpores.entries()) {
|
||||
if (s.nodeId === otherNodeId && !visited.has(id)) {
|
||||
queue.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a fruiting body from connected spores
|
||||
*/
|
||||
private spawnFruitingBody(spores: PlantedSpore[]): FruitingBody {
|
||||
// Determine fruiting body type based on spore composition
|
||||
const type = this.determineFruitType(spores);
|
||||
|
||||
// Calculate center position
|
||||
const centerGeohash = this.calculateCenterGeohash(spores);
|
||||
|
||||
// Collect contributors
|
||||
const contributors = [...new Set(spores.map((s) => s.planterPubKey))];
|
||||
|
||||
// Generate rewards based on type and contributor count
|
||||
const rewards = this.generateFruitRewards(type, spores.length, contributors.length);
|
||||
|
||||
// Calculate decay time based on fruit type
|
||||
const lifespanMinutes = this.getFruitLifespan(type);
|
||||
const now = new Date();
|
||||
|
||||
const fruit: FruitingBody = {
|
||||
id: `fruit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
type,
|
||||
locationCommitment: {
|
||||
geohash: centerGeohash,
|
||||
hash: '', // Would be calculated
|
||||
timestamp: now,
|
||||
precision: 7,
|
||||
},
|
||||
sourceSporeIds: spores.map((s) => s.id),
|
||||
harvestableRewards: rewards,
|
||||
emergedAt: now,
|
||||
decaysAt: new Date(now.getTime() + lifespanMinutes * 60 * 1000),
|
||||
maturity: 0,
|
||||
contributors,
|
||||
};
|
||||
|
||||
this.fruitingBodies.set(fruit.id, fruit);
|
||||
|
||||
this.emit({ type: 'fruit:emerged', fruit });
|
||||
|
||||
// Notify network
|
||||
this.network.emit({
|
||||
id: `signal-fruit-${fruit.id}`,
|
||||
type: 'discovery',
|
||||
sourceId: spores[0].nodeId,
|
||||
strength: 1,
|
||||
timestamp: now,
|
||||
data: { fruitId: fruit.id, type },
|
||||
});
|
||||
|
||||
return fruit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine fruiting body type from spore composition
|
||||
*/
|
||||
private determineFruitType(spores: PlantedSpore[]): FruitingBodyType {
|
||||
const typeCounts: Record<SporeType, number> = {
|
||||
explorer: 0,
|
||||
connector: 0,
|
||||
amplifier: 0,
|
||||
guardian: 0,
|
||||
harvester: 0,
|
||||
temporal: 0,
|
||||
social: 0,
|
||||
};
|
||||
|
||||
for (const spore of spores) {
|
||||
typeCounts[spore.spore.type]++;
|
||||
}
|
||||
|
||||
// Legendary fruit: all different types
|
||||
const uniqueTypes = Object.values(typeCounts).filter((c) => c > 0).length;
|
||||
if (uniqueTypes >= 5) return 'giant';
|
||||
|
||||
// Temporal fruit: mostly temporal spores
|
||||
if (typeCounts.temporal >= spores.length * 0.5) return 'temporal';
|
||||
|
||||
// Social/symbiotic: requires multiple contributors
|
||||
const contributors = new Set(spores.map((s) => s.planterPubKey)).size;
|
||||
if (contributors >= 3) return 'symbiotic';
|
||||
|
||||
// Bioluminescent: amplifier dominant
|
||||
if (typeCounts.amplifier >= spores.length * 0.4) return 'bioluminescent';
|
||||
|
||||
// Cluster: guardian dominant
|
||||
if (typeCounts.guardian >= spores.length * 0.4) return 'cluster';
|
||||
|
||||
return 'common';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate rewards for a fruiting body
|
||||
*/
|
||||
private generateFruitRewards(
|
||||
type: FruitingBodyType,
|
||||
sporeCount: number,
|
||||
contributorCount: number
|
||||
): DiscoveryReward[] {
|
||||
const rewards: DiscoveryReward[] = [];
|
||||
|
||||
// Base rewards by type
|
||||
const rewardConfig: Record<
|
||||
FruitingBodyType,
|
||||
{ type: DiscoveryReward['type']; rarity: DiscoveryReward['rarity']; quantity: number }
|
||||
> = {
|
||||
common: { type: 'spore', rarity: 'common', quantity: 2 },
|
||||
cluster: { type: 'spore', rarity: 'uncommon', quantity: 3 },
|
||||
giant: { type: 'collectible', rarity: 'epic', quantity: 1 },
|
||||
bioluminescent: { type: 'hint', rarity: 'rare', quantity: 1 },
|
||||
symbiotic: { type: 'points', rarity: 'rare', quantity: 100 * contributorCount },
|
||||
temporal: { type: 'experience', rarity: 'uncommon', quantity: 50 },
|
||||
};
|
||||
|
||||
const config = rewardConfig[type];
|
||||
|
||||
rewards.push({
|
||||
type: config.type,
|
||||
rewardId: `fruit-reward-${type}`,
|
||||
quantity: config.quantity + Math.floor(sporeCount / 2),
|
||||
rarity: config.rarity,
|
||||
});
|
||||
|
||||
// Bonus for multiple contributors
|
||||
if (contributorCount > 1) {
|
||||
rewards.push({
|
||||
type: 'points',
|
||||
rewardId: 'collaboration-bonus',
|
||||
quantity: 25 * contributorCount,
|
||||
rarity: 'common',
|
||||
});
|
||||
}
|
||||
|
||||
return rewards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lifespan for fruit type in minutes
|
||||
*/
|
||||
private getFruitLifespan(type: FruitingBodyType): number {
|
||||
const lifespans: Record<FruitingBodyType, number> = {
|
||||
common: 60, // 1 hour
|
||||
cluster: 120, // 2 hours
|
||||
giant: 360, // 6 hours
|
||||
bioluminescent: 30, // 30 minutes (rare, must be quick)
|
||||
symbiotic: 240, // 4 hours (need coordination)
|
||||
temporal: 15, // 15 minutes (very brief)
|
||||
};
|
||||
return lifespans[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvest a fruiting body
|
||||
*/
|
||||
harvestFruit(
|
||||
fruitId: string,
|
||||
playerPubKey: string
|
||||
): { success: boolean; rewards?: DiscoveryReward[]; error?: string } {
|
||||
const fruit = this.fruitingBodies.get(fruitId);
|
||||
if (!fruit) {
|
||||
return { success: false, error: 'Fruiting body not found' };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (now > fruit.decaysAt) {
|
||||
this.fruitingBodies.delete(fruitId);
|
||||
return { success: false, error: 'Fruiting body has decayed' };
|
||||
}
|
||||
|
||||
if (fruit.maturity < 100) {
|
||||
return { success: false, error: 'Fruiting body not mature yet' };
|
||||
}
|
||||
|
||||
// Symbiotic fruits require a contributor to harvest
|
||||
if (fruit.type === 'symbiotic' && !fruit.contributors.includes(playerPubKey)) {
|
||||
return { success: false, error: 'Only contributors can harvest symbiotic fruits' };
|
||||
}
|
||||
|
||||
// Collect rewards
|
||||
const rewards = [...fruit.harvestableRewards];
|
||||
|
||||
// Remove fruit
|
||||
this.fruitingBodies.delete(fruitId);
|
||||
|
||||
this.emit({ type: 'fruit:harvested', fruitId, playerId: playerPubKey });
|
||||
|
||||
return { success: true, rewards };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Growth Simulation
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Start the growth simulation
|
||||
*/
|
||||
startSimulation(): void {
|
||||
if (this.tickTimer) return;
|
||||
|
||||
this.tickTimer = setInterval(() => {
|
||||
this.tick();
|
||||
}, this.config.tickInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the growth simulation
|
||||
*/
|
||||
stopSimulation(): void {
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process one simulation tick
|
||||
*/
|
||||
tick(): void {
|
||||
const now = new Date();
|
||||
|
||||
// Update spore nutrients
|
||||
for (const [id, spore] of this.plantedSpores.entries()) {
|
||||
// Decay nutrients
|
||||
spore.nutrients -= this.config.nutrientDecayRate;
|
||||
|
||||
// Check for death
|
||||
if (spore.nutrients <= 0) {
|
||||
this.removeSpore(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Grow hyphae
|
||||
this.growHyphae(spore);
|
||||
}
|
||||
|
||||
// Mature fruiting bodies
|
||||
for (const [id, fruit] of this.fruitingBodies.entries()) {
|
||||
// Check decay
|
||||
if (now > fruit.decaysAt) {
|
||||
this.fruitingBodies.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Increase maturity
|
||||
const ageMs = now.getTime() - fruit.emergedAt.getTime();
|
||||
const lifespanMs = fruit.decaysAt.getTime() - fruit.emergedAt.getTime();
|
||||
fruit.maturity = Math.min(100, (ageMs / lifespanMs) * 100 * 2); // Mature at 50% lifespan
|
||||
}
|
||||
|
||||
// Update network
|
||||
this.network.propagateSignals(0.9);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grow hyphae from a spore
|
||||
*/
|
||||
private growHyphae(spore: PlantedSpore): void {
|
||||
const growthRate = spore.spore.growthRate * this.config.baseGrowthRate;
|
||||
|
||||
// Try to extend existing hyphae or create new connections
|
||||
for (const hyphaId of spore.hyphaIds) {
|
||||
const hypha = this.network.getHypha(hyphaId);
|
||||
if (hypha) {
|
||||
// Strengthen existing connection
|
||||
hypha.strength = Math.min(1, hypha.strength + growthRate * 0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a dead spore
|
||||
*/
|
||||
private removeSpore(sporeId: string): void {
|
||||
const spore = this.plantedSpores.get(sporeId);
|
||||
if (!spore) return;
|
||||
|
||||
// Remove from network
|
||||
this.network.removeNode(spore.nodeId);
|
||||
|
||||
// Update player count
|
||||
const count = this.playerSporeCount.get(spore.planterPubKey) ?? 1;
|
||||
this.playerSporeCount.set(spore.planterPubKey, Math.max(0, count - 1));
|
||||
|
||||
this.plantedSpores.delete(sporeId);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Utility Functions
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Convert geohash to approximate position
|
||||
*/
|
||||
private geohashToPosition(geohash: string): { x: number; y: number } {
|
||||
// Simplified conversion - in production, use proper decoding
|
||||
let x = 0,
|
||||
y = 0;
|
||||
for (let i = 0; i < geohash.length; i++) {
|
||||
const code = geohash.charCodeAt(i);
|
||||
x += code * Math.pow(32, geohash.length - i - 1);
|
||||
y += (code * 7) % 100;
|
||||
}
|
||||
return { x: x % 10000, y: y * 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between positions
|
||||
*/
|
||||
private calculateDistance(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number }
|
||||
): number {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate center geohash of multiple spores
|
||||
*/
|
||||
private calculateCenterGeohash(spores: PlantedSpore[]): string {
|
||||
// Simplified - just use first spore's geohash
|
||||
// In production, calculate actual center
|
||||
return spores[0].locationCommitment.geohash;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Queries
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get planted spore by ID
|
||||
*/
|
||||
getPlantedSpore(id: string): PlantedSpore | undefined {
|
||||
return this.plantedSpores.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all planted spores
|
||||
*/
|
||||
getAllPlantedSpores(): PlantedSpore[] {
|
||||
return Array.from(this.plantedSpores.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player's planted spores
|
||||
*/
|
||||
getPlayerSpores(playerPubKey: string): PlantedSpore[] {
|
||||
return Array.from(this.plantedSpores.values()).filter(
|
||||
(s) => s.planterPubKey === playerPubKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fruiting body by ID
|
||||
*/
|
||||
getFruitingBody(id: string): FruitingBody | undefined {
|
||||
return this.fruitingBodies.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fruiting bodies
|
||||
*/
|
||||
getAllFruitingBodies(): FruitingBody[] {
|
||||
return Array.from(this.fruitingBodies.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mature fruiting bodies
|
||||
*/
|
||||
getMatureFruits(): FruitingBody[] {
|
||||
return Array.from(this.fruitingBodies.values()).filter((f) => f.maturity >= 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying mycelium network
|
||||
*/
|
||||
getNetwork(): MyceliumNetwork {
|
||||
return this.network;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Events
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(listener: GameEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private emit(event: GameEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('Error in game event listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Serialization
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Export state
|
||||
*/
|
||||
export(): string {
|
||||
return JSON.stringify({
|
||||
plantedSpores: Array.from(this.plantedSpores.entries()),
|
||||
fruitingBodies: Array.from(this.fruitingBodies.entries()),
|
||||
playerSporeCount: Array.from(this.playerSporeCount.entries()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import state
|
||||
*/
|
||||
import(json: string): void {
|
||||
const data = JSON.parse(json);
|
||||
this.plantedSpores = new Map(data.plantedSpores);
|
||||
this.fruitingBodies = new Map(data.fruitingBodies);
|
||||
this.playerSporeCount = new Map(data.playerSporeCount);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a spore manager
|
||||
*/
|
||||
export function createSporeManager(config?: Partial<SporeSystemConfig>): SporeManager {
|
||||
return new SporeManager(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a spore from type
|
||||
*/
|
||||
export function createSporeFromType(type: SporeType): Spore {
|
||||
const template = SPORE_TEMPLATES[type];
|
||||
return {
|
||||
...template,
|
||||
id: `spore-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,876 @@
|
|||
/**
|
||||
* zkGPS Location Games and Discovery System
|
||||
*
|
||||
* A framework for privacy-preserving location-based games, treasure hunts,
|
||||
* and collaborative discovery experiences. Uses zkGPS proofs to verify
|
||||
* proximity without revealing exact locations.
|
||||
*
|
||||
* Core concepts:
|
||||
* - Anchors: Hidden locations that can be discovered
|
||||
* - Discoveries: Proof that a player found an anchor
|
||||
* - Collectibles: Items earned through discoveries
|
||||
* - Spores: Mycelial elements that grow networks between discoveries
|
||||
* - Hunts: Organized games with multiple anchors and rewards
|
||||
*/
|
||||
|
||||
import type { GeohashCommitment, ProximityProof, TrustLevel } from '../privacy/types';
|
||||
import type { MyceliumNode, Hypha, Signal } from '../mycelium/types';
|
||||
|
||||
// =============================================================================
|
||||
// Discovery Anchors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Types of physical/virtual anchors for discoveries
|
||||
*/
|
||||
export type AnchorType =
|
||||
| 'physical' // Real-world location only
|
||||
| 'nfc' // NFC tag required
|
||||
| 'qr' // QR code scan required
|
||||
| 'ble' // BLE beacon proximity
|
||||
| 'virtual' // AR/virtual overlay
|
||||
| 'temporal' // Only exists at certain times
|
||||
| 'social' // Requires group presence
|
||||
| 'composite'; // Combination of above
|
||||
|
||||
/**
|
||||
* Visibility states for anchors
|
||||
*/
|
||||
export type AnchorVisibility =
|
||||
| 'hidden' // No hints, must stumble upon
|
||||
| 'hinted' // Hot/cold navigation available
|
||||
| 'revealed' // Location shown after condition met
|
||||
| 'public'; // Always visible on map
|
||||
|
||||
/**
|
||||
* A discovery anchor - a hidden or semi-hidden location
|
||||
*/
|
||||
export interface DiscoveryAnchor {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Human-readable name (may be hidden until discovered) */
|
||||
name: string;
|
||||
|
||||
/** Description revealed upon discovery */
|
||||
description: string;
|
||||
|
||||
/** Type of anchor */
|
||||
type: AnchorType;
|
||||
|
||||
/** Current visibility state */
|
||||
visibility: AnchorVisibility;
|
||||
|
||||
/** zkGPS commitment hiding the location */
|
||||
locationCommitment: GeohashCommitment;
|
||||
|
||||
/** Geohash precision required for discovery (1-12) */
|
||||
requiredPrecision: number;
|
||||
|
||||
/** Optional time window when anchor is active */
|
||||
activeWindow?: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
recurring?: 'daily' | 'weekly' | 'monthly';
|
||||
};
|
||||
|
||||
/** IoT hardware requirements */
|
||||
iotRequirements?: IoTRequirement[];
|
||||
|
||||
/** Social requirements (group size, trust levels) */
|
||||
socialRequirements?: SocialRequirement;
|
||||
|
||||
/** Rewards for discovering this anchor */
|
||||
rewards: DiscoveryReward[];
|
||||
|
||||
/** Clues/hints for finding this anchor */
|
||||
hints: AnchorHint[];
|
||||
|
||||
/** Prerequisites (other anchors that must be found first) */
|
||||
prerequisites: string[];
|
||||
|
||||
/** Metadata */
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
/** Creator's public key */
|
||||
creatorPubKey: string;
|
||||
|
||||
/** Creation timestamp */
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT hardware requirement for discovery
|
||||
*/
|
||||
export interface IoTRequirement {
|
||||
/** Type of hardware */
|
||||
type: 'nfc' | 'ble' | 'qr' | 'rfid' | 'gps-rtk';
|
||||
|
||||
/** Hardware identifier or pattern */
|
||||
identifier: string;
|
||||
|
||||
/** Challenge data that must be signed/returned */
|
||||
challenge?: string;
|
||||
|
||||
/** Expected response hash */
|
||||
expectedResponseHash?: string;
|
||||
|
||||
/** Signal strength requirement for BLE */
|
||||
minRssi?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Social requirements for group discoveries
|
||||
*/
|
||||
export interface SocialRequirement {
|
||||
/** Minimum players in proximity */
|
||||
minPlayers: number;
|
||||
|
||||
/** Maximum players (for exclusive discoveries) */
|
||||
maxPlayers?: number;
|
||||
|
||||
/** Required trust level between players */
|
||||
minTrustLevel: TrustLevel;
|
||||
|
||||
/** All players must be within this geohash precision of each other */
|
||||
groupProximityPrecision: number;
|
||||
|
||||
/** Specific player public keys required (for invite-only) */
|
||||
requiredPlayers?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hints for finding an anchor
|
||||
*/
|
||||
export interface AnchorHint {
|
||||
/** Hint identifier */
|
||||
id: string;
|
||||
|
||||
/** When this hint is revealed */
|
||||
revealCondition: HintRevealCondition;
|
||||
|
||||
/** The hint content (riddle, direction, image, etc.) */
|
||||
content: HintContent;
|
||||
|
||||
/** Precision level this hint provides (for hot/cold) */
|
||||
precisionLevel?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditions for revealing hints
|
||||
*/
|
||||
export type HintRevealCondition =
|
||||
| { type: 'immediate' }
|
||||
| { type: 'proximity'; precision: number }
|
||||
| { type: 'time'; afterMinutes: number }
|
||||
| { type: 'discovery'; anchorId: string }
|
||||
| { type: 'payment'; amount: number; token: string }
|
||||
| { type: 'social'; minPlayers: number };
|
||||
|
||||
/**
|
||||
* Hint content types
|
||||
*/
|
||||
export type HintContent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'riddle'; riddle: string; answer?: string }
|
||||
| { type: 'image'; imageUrl: string; caption?: string }
|
||||
| { type: 'audio'; audioUrl: string }
|
||||
| { type: 'direction'; bearing: number; distance?: 'near' | 'medium' | 'far' }
|
||||
| { type: 'hotCold'; temperature: number } // 0-100, 100 = on top of it
|
||||
| { type: 'geohashPrefix'; prefix: string }; // Partial location reveal
|
||||
|
||||
// =============================================================================
|
||||
// Discoveries
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A verified discovery of an anchor
|
||||
*/
|
||||
export interface Discovery {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** The anchor that was discovered */
|
||||
anchorId: string;
|
||||
|
||||
/** Player who made the discovery */
|
||||
playerPubKey: string;
|
||||
|
||||
/** zkGPS proximity proof */
|
||||
proximityProof: ProximityProof;
|
||||
|
||||
/** IoT verification data (if required) */
|
||||
iotVerification?: IoTVerification;
|
||||
|
||||
/** Group discovery data (if social anchor) */
|
||||
groupDiscovery?: GroupDiscovery;
|
||||
|
||||
/** Timestamp of discovery */
|
||||
timestamp: Date;
|
||||
|
||||
/** Whether this was first discovery */
|
||||
isFirstFinder: boolean;
|
||||
|
||||
/** Discovery order (1st, 2nd, 3rd, etc.) */
|
||||
discoveryOrder: number;
|
||||
|
||||
/** Rewards claimed */
|
||||
rewardsClaimed: ClaimedReward[];
|
||||
|
||||
/** Signature from player */
|
||||
playerSignature: string;
|
||||
|
||||
/** Optional witness signatures */
|
||||
witnessSignatures?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT verification proof
|
||||
*/
|
||||
export interface IoTVerification {
|
||||
/** Hardware type */
|
||||
type: 'nfc' | 'ble' | 'qr' | 'rfid';
|
||||
|
||||
/** Challenge response */
|
||||
challengeResponse: string;
|
||||
|
||||
/** Hardware signature (if capable) */
|
||||
hardwareSignature?: string;
|
||||
|
||||
/** Signal strength for BLE */
|
||||
rssi?: number;
|
||||
|
||||
/** Timestamp from hardware */
|
||||
hardwareTimestamp?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group discovery proof
|
||||
*/
|
||||
export interface GroupDiscovery {
|
||||
/** All player public keys */
|
||||
playerPubKeys: string[];
|
||||
|
||||
/** Group proximity proof */
|
||||
groupProximityProof: ProximityProof;
|
||||
|
||||
/** Individual proximity proofs from each player */
|
||||
individualProofs: ProximityProof[];
|
||||
|
||||
/** Timestamp when all players were verified in proximity */
|
||||
verifiedAt: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Rewards and Collectibles
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Reward types for discoveries
|
||||
*/
|
||||
export type RewardType =
|
||||
| 'collectible' // Unique item
|
||||
| 'spore' // Mycelium spore for network growth
|
||||
| 'hint' // Reveals hint for another anchor
|
||||
| 'key' // Unlocks another anchor
|
||||
| 'badge' // Achievement badge
|
||||
| 'points' // Point score
|
||||
| 'token' // Cryptocurrency/token reward
|
||||
| 'experience'; // XP for leveling
|
||||
|
||||
/**
|
||||
* Discovery reward definition
|
||||
*/
|
||||
export interface DiscoveryReward {
|
||||
/** Reward type */
|
||||
type: RewardType;
|
||||
|
||||
/** Reward identifier or item ID */
|
||||
rewardId: string;
|
||||
|
||||
/** Quantity (for fungible rewards) */
|
||||
quantity: number;
|
||||
|
||||
/** Rarity (affects drop chance for random rewards) */
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
/** Only awarded to first N finders */
|
||||
firstFinderOnly?: number;
|
||||
|
||||
/** Probability of receiving (0-1, for random drops) */
|
||||
dropChance?: number;
|
||||
|
||||
/** Conditions for receiving reward */
|
||||
conditions?: RewardCondition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditions for receiving rewards
|
||||
*/
|
||||
export type RewardCondition =
|
||||
| { type: 'firstFinder'; rank: number }
|
||||
| { type: 'timeLimit'; withinMinutes: number }
|
||||
| { type: 'groupSize'; minPlayers: number }
|
||||
| { type: 'hasItem'; itemId: string }
|
||||
| { type: 'level'; minLevel: number };
|
||||
|
||||
/**
|
||||
* A claimed reward
|
||||
*/
|
||||
export interface ClaimedReward {
|
||||
/** Reward definition */
|
||||
reward: DiscoveryReward;
|
||||
|
||||
/** When claimed */
|
||||
claimedAt: Date;
|
||||
|
||||
/** Transaction hash (for on-chain rewards) */
|
||||
txHash?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Collectibles and Crafting
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Collectible item categories
|
||||
*/
|
||||
export type CollectibleCategory =
|
||||
| 'spore' // Mycelium spores
|
||||
| 'fragment' // Pieces that combine
|
||||
| 'artifact' // Complete unique items
|
||||
| 'tool' // Usable items
|
||||
| 'key' // Unlock items
|
||||
| 'map' // Reveals locations
|
||||
| 'badge' // Achievement display
|
||||
| 'material'; // Crafting ingredients
|
||||
|
||||
/**
|
||||
* A collectible item
|
||||
*/
|
||||
export interface Collectible {
|
||||
/** Unique item ID */
|
||||
id: string;
|
||||
|
||||
/** Item name */
|
||||
name: string;
|
||||
|
||||
/** Description */
|
||||
description: string;
|
||||
|
||||
/** Category */
|
||||
category: CollectibleCategory;
|
||||
|
||||
/** Rarity */
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
/** Visual representation */
|
||||
visual: {
|
||||
imageUrl: string;
|
||||
iconUrl?: string;
|
||||
color?: string;
|
||||
animation?: string;
|
||||
};
|
||||
|
||||
/** Item properties/stats */
|
||||
properties: Record<string, number | string | boolean>;
|
||||
|
||||
/** Whether item is tradeable */
|
||||
tradeable: boolean;
|
||||
|
||||
/** Whether item is consumable (one-time use) */
|
||||
consumable: boolean;
|
||||
|
||||
/** Stack limit (1 = non-stackable) */
|
||||
stackLimit: number;
|
||||
|
||||
/** Crafting recipes this item is used in */
|
||||
usedInRecipes: string[];
|
||||
|
||||
/** Special abilities/effects */
|
||||
abilities?: ItemAbility[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Item ability/effect
|
||||
*/
|
||||
export interface ItemAbility {
|
||||
/** Ability name */
|
||||
name: string;
|
||||
|
||||
/** What it does */
|
||||
effect: ItemEffect;
|
||||
|
||||
/** Cooldown in seconds */
|
||||
cooldownSeconds?: number;
|
||||
|
||||
/** Uses remaining (-1 = unlimited) */
|
||||
uses: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item effects
|
||||
*/
|
||||
export type ItemEffect =
|
||||
| { type: 'revealHint'; anchorId: string }
|
||||
| { type: 'unlockAnchor'; anchorId: string }
|
||||
| { type: 'boostPrecision'; precisionBoost: number; durationMinutes: number }
|
||||
| { type: 'extendRange'; rangeMultiplier: number; durationMinutes: number }
|
||||
| { type: 'groupLink'; maxDistance: number }
|
||||
| { type: 'plantSpore'; sporeType: string }
|
||||
| { type: 'harvestFruit'; yieldMultiplier: number };
|
||||
|
||||
/**
|
||||
* Crafting recipe
|
||||
*/
|
||||
export interface CraftingRecipe {
|
||||
/** Recipe ID */
|
||||
id: string;
|
||||
|
||||
/** Recipe name */
|
||||
name: string;
|
||||
|
||||
/** Required ingredients */
|
||||
ingredients: CraftingIngredient[];
|
||||
|
||||
/** Resulting item(s) */
|
||||
outputs: CraftingOutput[];
|
||||
|
||||
/** Time to craft in seconds */
|
||||
craftingTime: number;
|
||||
|
||||
/** Location requirements (must craft at specific anchor) */
|
||||
locationRequirement?: string;
|
||||
|
||||
/** Level requirement */
|
||||
levelRequirement?: number;
|
||||
|
||||
/** Whether recipe is known by default or must be discovered */
|
||||
discoverable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crafting ingredient
|
||||
*/
|
||||
export interface CraftingIngredient {
|
||||
/** Item ID */
|
||||
itemId: string;
|
||||
|
||||
/** Quantity required */
|
||||
quantity: number;
|
||||
|
||||
/** Whether item is consumed */
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crafting output
|
||||
*/
|
||||
export interface CraftingOutput {
|
||||
/** Item ID */
|
||||
itemId: string;
|
||||
|
||||
/** Quantity produced */
|
||||
quantity: number;
|
||||
|
||||
/** Probability (for random outputs) */
|
||||
probability?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mycelium Integration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Spore types for mycelium network growth
|
||||
*/
|
||||
export type SporeType =
|
||||
| 'explorer' // Spreads quickly, reveals area
|
||||
| 'connector' // Links discoveries together
|
||||
| 'amplifier' // Boosts signal strength
|
||||
| 'guardian' // Protects territory
|
||||
| 'harvester' // Increases rewards
|
||||
| 'temporal' // Affects time-based mechanics
|
||||
| 'social'; // Enhances group bonuses
|
||||
|
||||
/**
|
||||
* A spore that can be planted to grow mycelium
|
||||
*/
|
||||
export interface Spore {
|
||||
/** Spore ID */
|
||||
id: string;
|
||||
|
||||
/** Spore type */
|
||||
type: SporeType;
|
||||
|
||||
/** Growth rate multiplier */
|
||||
growthRate: number;
|
||||
|
||||
/** Maximum hypha length this spore can produce */
|
||||
maxReach: number;
|
||||
|
||||
/** Nutrient capacity (how long it lives) */
|
||||
nutrientCapacity: number;
|
||||
|
||||
/** Special properties */
|
||||
properties: Record<string, number>;
|
||||
|
||||
/** Visual style */
|
||||
visual: {
|
||||
color: string;
|
||||
pattern: 'radial' | 'branching' | 'spiral' | 'clustered';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A planted spore growing into mycelium
|
||||
*/
|
||||
export interface PlantedSpore {
|
||||
/** Instance ID */
|
||||
id: string;
|
||||
|
||||
/** Spore template */
|
||||
spore: Spore;
|
||||
|
||||
/** Location commitment where planted */
|
||||
locationCommitment: GeohashCommitment;
|
||||
|
||||
/** Player who planted */
|
||||
planterPubKey: string;
|
||||
|
||||
/** When planted */
|
||||
plantedAt: Date;
|
||||
|
||||
/** Current nutrient level (0-100) */
|
||||
nutrients: number;
|
||||
|
||||
/** Mycelium node created from this spore */
|
||||
nodeId: string;
|
||||
|
||||
/** Hyphae grown from this spore */
|
||||
hyphaIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fruiting body - emerges when mycelium networks connect
|
||||
*/
|
||||
export interface FruitingBody {
|
||||
/** Unique ID */
|
||||
id: string;
|
||||
|
||||
/** Type of fruiting body */
|
||||
type: FruitingBodyType;
|
||||
|
||||
/** Location commitment */
|
||||
locationCommitment: GeohashCommitment;
|
||||
|
||||
/** Connected spore IDs that created this */
|
||||
sourceSporeIds: string[];
|
||||
|
||||
/** Rewards available for harvest */
|
||||
harvestableRewards: DiscoveryReward[];
|
||||
|
||||
/** When it emerged */
|
||||
emergedAt: Date;
|
||||
|
||||
/** How long until it decays */
|
||||
decaysAt: Date;
|
||||
|
||||
/** Current maturity (0-100) */
|
||||
maturity: number;
|
||||
|
||||
/** Players who contributed to creation */
|
||||
contributors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of fruiting bodies
|
||||
*/
|
||||
export type FruitingBodyType =
|
||||
| 'common' // Basic rewards
|
||||
| 'cluster' // Multiple smaller rewards
|
||||
| 'giant' // Rare, large rewards
|
||||
| 'bioluminescent' // Reveals hidden anchors
|
||||
| 'symbiotic' // Requires multiple players to harvest
|
||||
| 'temporal'; // Only exists briefly
|
||||
|
||||
// =============================================================================
|
||||
// Treasure Hunts
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* An organized treasure hunt with multiple anchors
|
||||
*/
|
||||
export interface TreasureHunt {
|
||||
/** Hunt ID */
|
||||
id: string;
|
||||
|
||||
/** Hunt name */
|
||||
name: string;
|
||||
|
||||
/** Description */
|
||||
description: string;
|
||||
|
||||
/** Hunt creator */
|
||||
creatorPubKey: string;
|
||||
|
||||
/** All anchors in this hunt */
|
||||
anchorIds: string[];
|
||||
|
||||
/** Order matters? */
|
||||
sequential: boolean;
|
||||
|
||||
/** Time limits */
|
||||
timing: {
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
maxDurationMinutes?: number; // Per-player time limit
|
||||
};
|
||||
|
||||
/** Participation rules */
|
||||
participation: {
|
||||
maxPlayers?: number;
|
||||
teamSize?: { min: number; max: number };
|
||||
entryFee?: { amount: number; token: string };
|
||||
inviteOnly: boolean;
|
||||
allowedPlayers?: string[];
|
||||
};
|
||||
|
||||
/** Scoring system */
|
||||
scoring: HuntScoring;
|
||||
|
||||
/** Grand prizes for winners */
|
||||
prizes: HuntPrize[];
|
||||
|
||||
/** Current hunt state */
|
||||
state: 'upcoming' | 'active' | 'completed' | 'cancelled';
|
||||
|
||||
/** Leaderboard */
|
||||
leaderboard: LeaderboardEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hunt scoring configuration
|
||||
*/
|
||||
export interface HuntScoring {
|
||||
/** Points per discovery */
|
||||
pointsPerDiscovery: number;
|
||||
|
||||
/** Bonus for first finder */
|
||||
firstFinderBonus: number;
|
||||
|
||||
/** Time bonus (points per minute under par) */
|
||||
timeBonus?: number;
|
||||
|
||||
/** Bonus for completing in sequence */
|
||||
sequenceBonus?: number;
|
||||
|
||||
/** Bonus for group discovery */
|
||||
groupBonus?: number;
|
||||
|
||||
/** Multiplier for rare finds */
|
||||
rarityMultiplier: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hunt prizes
|
||||
*/
|
||||
export interface HuntPrize {
|
||||
/** Position (1st, 2nd, 3rd, etc.) */
|
||||
position: number;
|
||||
|
||||
/** Prize description */
|
||||
description: string;
|
||||
|
||||
/** Rewards */
|
||||
rewards: DiscoveryReward[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaderboard entry
|
||||
*/
|
||||
export interface LeaderboardEntry {
|
||||
/** Player or team ID */
|
||||
playerId: string;
|
||||
|
||||
/** Display name */
|
||||
displayName: string;
|
||||
|
||||
/** Total score */
|
||||
score: number;
|
||||
|
||||
/** Discoveries made */
|
||||
discoveriesCount: number;
|
||||
|
||||
/** First finds */
|
||||
firstFindsCount: number;
|
||||
|
||||
/** Time taken (for timed hunts) */
|
||||
timeSeconds?: number;
|
||||
|
||||
/** Position on leaderboard */
|
||||
rank: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Player State
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Player's game state
|
||||
*/
|
||||
export interface PlayerState {
|
||||
/** Player public key */
|
||||
pubKey: string;
|
||||
|
||||
/** Display name */
|
||||
displayName: string;
|
||||
|
||||
/** Current level */
|
||||
level: number;
|
||||
|
||||
/** Experience points */
|
||||
xp: number;
|
||||
|
||||
/** Inventory */
|
||||
inventory: InventorySlot[];
|
||||
|
||||
/** Discoveries made */
|
||||
discoveries: string[];
|
||||
|
||||
/** Active hunts */
|
||||
activeHunts: string[];
|
||||
|
||||
/** Planted spores */
|
||||
plantedSpores: string[];
|
||||
|
||||
/** Badges earned */
|
||||
badges: string[];
|
||||
|
||||
/** Stats */
|
||||
stats: PlayerStats;
|
||||
|
||||
/** Preferences */
|
||||
preferences: PlayerPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory slot
|
||||
*/
|
||||
export interface InventorySlot {
|
||||
/** Item ID */
|
||||
itemId: string;
|
||||
|
||||
/** Quantity */
|
||||
quantity: number;
|
||||
|
||||
/** Slot position */
|
||||
slot: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player statistics
|
||||
*/
|
||||
export interface PlayerStats {
|
||||
totalDiscoveries: number;
|
||||
firstFinds: number;
|
||||
huntsCompleted: number;
|
||||
huntsWon: number;
|
||||
sporesPlanted: number;
|
||||
fruitHarvested: number;
|
||||
distanceTraveled: number;
|
||||
itemsCrafted: number;
|
||||
itemsTraded: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player preferences
|
||||
*/
|
||||
export interface PlayerPreferences {
|
||||
/** Share discoveries publicly */
|
||||
shareDiscoveries: boolean;
|
||||
|
||||
/** Allow location hints to others */
|
||||
provideHints: boolean;
|
||||
|
||||
/** Notification settings */
|
||||
notifications: {
|
||||
newHunts: boolean;
|
||||
nearbyDiscoveries: boolean;
|
||||
fruitReady: boolean;
|
||||
groupInvites: boolean;
|
||||
};
|
||||
|
||||
/** Privacy level for presence */
|
||||
presencePrivacy: TrustLevel;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Events
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Game events
|
||||
*/
|
||||
export type GameEvent =
|
||||
| { type: 'anchor:created'; anchor: DiscoveryAnchor }
|
||||
| { type: 'anchor:discovered'; discovery: Discovery }
|
||||
| { type: 'anchor:firstFind'; discovery: Discovery; rank: number }
|
||||
| { type: 'hint:revealed'; anchorId: string; hint: AnchorHint }
|
||||
| { type: 'reward:claimed'; reward: ClaimedReward; playerId: string }
|
||||
| { type: 'item:crafted'; itemId: string; playerId: string }
|
||||
| { type: 'spore:planted'; spore: PlantedSpore }
|
||||
| { type: 'fruit:emerged'; fruit: FruitingBody }
|
||||
| { type: 'fruit:harvested'; fruitId: string; playerId: string }
|
||||
| { type: 'hunt:started'; hunt: TreasureHunt }
|
||||
| { type: 'hunt:completed'; hunt: TreasureHunt; winnerId: string }
|
||||
| { type: 'player:levelUp'; playerId: string; newLevel: number }
|
||||
| { type: 'group:formed'; playerIds: string[] }
|
||||
| { type: 'network:connected'; sporeIds: string[] };
|
||||
|
||||
export type GameEventListener = (event: GameEvent) => void;
|
||||
|
||||
// =============================================================================
|
||||
// Hot/Cold Navigation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Navigation hint based on proximity
|
||||
*/
|
||||
export interface NavigationHint {
|
||||
/** Target anchor ID */
|
||||
anchorId: string;
|
||||
|
||||
/** Temperature (0 = freezing, 100 = burning hot) */
|
||||
temperature: number;
|
||||
|
||||
/** Qualitative description */
|
||||
description: 'freezing' | 'cold' | 'cool' | 'warm' | 'hot' | 'burning';
|
||||
|
||||
/** Direction hint (optional, based on trust/items) */
|
||||
direction?: {
|
||||
bearing: number;
|
||||
confidence: 'low' | 'medium' | 'high';
|
||||
};
|
||||
|
||||
/** Distance category */
|
||||
distance: 'far' | 'medium' | 'near' | 'close' | 'here';
|
||||
|
||||
/** Precision of player's current location */
|
||||
currentPrecision: number;
|
||||
|
||||
/** Required precision for discovery */
|
||||
requiredPrecision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temperature thresholds for hot/cold
|
||||
*/
|
||||
export const TEMPERATURE_THRESHOLDS = {
|
||||
freezing: { max: 10, geohashDiff: 6 }, // 6+ chars different
|
||||
cold: { max: 25, geohashDiff: 5 }, // 5 chars different
|
||||
cool: { max: 40, geohashDiff: 4 }, // 4 chars different
|
||||
warm: { max: 60, geohashDiff: 3 }, // 3 chars different
|
||||
hot: { max: 85, geohashDiff: 2 }, // 2 chars different
|
||||
burning: { max: 100, geohashDiff: 1 }, // 1 char different = very close
|
||||
} as const;
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { MapViewport, Coordinate, TileServiceConfig } from '../types';
|
||||
|
||||
interface UseMapInstanceOptions {
|
||||
|
|
@ -16,85 +18,247 @@ interface UseMapInstanceOptions {
|
|||
config: TileServiceConfig;
|
||||
initialViewport?: MapViewport;
|
||||
onViewportChange?: (viewport: MapViewport) => void;
|
||||
onClick?: (coordinate: Coordinate) => void;
|
||||
onClick?: (coordinate: Coordinate, event: maplibregl.MapMouseEvent) => void;
|
||||
onDoubleClick?: (coordinate: Coordinate, event: maplibregl.MapMouseEvent) => void;
|
||||
onMoveStart?: () => void;
|
||||
onMoveEnd?: (viewport: MapViewport) => void;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
interface UseMapInstanceReturn {
|
||||
isLoaded: boolean;
|
||||
error: Error | null;
|
||||
viewport: MapViewport;
|
||||
setViewport: (viewport: MapViewport) => void;
|
||||
flyTo: (coordinate: Coordinate, zoom?: number) => void;
|
||||
fitBounds: (bounds: [[number, number], [number, number]]) => void;
|
||||
getMap: () => unknown; // MapLibre map instance
|
||||
flyTo: (coordinate: Coordinate, zoom?: number, options?: maplibregl.FlyToOptions) => void;
|
||||
fitBounds: (bounds: [[number, number], [number, number]], options?: maplibregl.FitBoundsOptions) => void;
|
||||
getMap: () => maplibregl.Map | null;
|
||||
resize: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_VIEWPORT: MapViewport = {
|
||||
center: { lat: 0, lng: 0 },
|
||||
zoom: 2,
|
||||
center: { lat: 40.7128, lng: -74.006 }, // NYC default
|
||||
zoom: 10,
|
||||
bearing: 0,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
// Default style using OpenStreetMap tiles via MapLibre
|
||||
const DEFAULT_STYLE: maplibregl.StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-raster': {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-raster-layer',
|
||||
type: 'raster',
|
||||
source: 'osm-raster',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function useMapInstance({
|
||||
container,
|
||||
config,
|
||||
initialViewport = DEFAULT_VIEWPORT,
|
||||
onViewportChange,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onMoveStart,
|
||||
onMoveEnd,
|
||||
interactive = true,
|
||||
}: UseMapInstanceOptions): UseMapInstanceReturn {
|
||||
const mapRef = useRef<unknown>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [viewport, setViewportState] = useState<MapViewport>(initialViewport);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!container) return;
|
||||
|
||||
// TODO: Initialize MapLibre GL JS
|
||||
// const map = new maplibregl.Map({
|
||||
// container,
|
||||
// style: config.styleUrl,
|
||||
// center: [initialViewport.center.lng, initialViewport.center.lat],
|
||||
// zoom: initialViewport.zoom,
|
||||
// bearing: initialViewport.bearing,
|
||||
// pitch: initialViewport.pitch,
|
||||
// });
|
||||
// Prevent double initialization
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
}
|
||||
|
||||
console.log('useMapInstance: Would initialize map with config', config);
|
||||
setIsLoaded(true);
|
||||
try {
|
||||
const style = config.styleUrl || DEFAULT_STYLE;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container,
|
||||
style,
|
||||
center: [initialViewport.center.lng, initialViewport.center.lat],
|
||||
zoom: initialViewport.zoom,
|
||||
bearing: initialViewport.bearing,
|
||||
pitch: initialViewport.pitch,
|
||||
interactive,
|
||||
attributionControl: false,
|
||||
maxZoom: config.maxZoom ?? 19,
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
// Handle map load
|
||||
map.on('load', () => {
|
||||
setIsLoaded(true);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
// Handle map errors
|
||||
map.on('error', (e) => {
|
||||
console.error('MapLibre error:', e);
|
||||
setError(new Error(e.error?.message || 'Map error occurred'));
|
||||
});
|
||||
|
||||
// Handle viewport changes
|
||||
map.on('move', () => {
|
||||
const center = map.getCenter();
|
||||
const newViewport: MapViewport = {
|
||||
center: { lat: center.lat, lng: center.lng },
|
||||
zoom: map.getZoom(),
|
||||
bearing: map.getBearing(),
|
||||
pitch: map.getPitch(),
|
||||
};
|
||||
setViewportState(newViewport);
|
||||
onViewportChange?.(newViewport);
|
||||
});
|
||||
|
||||
// Handle move start/end
|
||||
map.on('movestart', () => {
|
||||
onMoveStart?.();
|
||||
});
|
||||
|
||||
map.on('moveend', () => {
|
||||
const center = map.getCenter();
|
||||
const finalViewport: MapViewport = {
|
||||
center: { lat: center.lat, lng: center.lng },
|
||||
zoom: map.getZoom(),
|
||||
bearing: map.getBearing(),
|
||||
pitch: map.getPitch(),
|
||||
};
|
||||
onMoveEnd?.(finalViewport);
|
||||
});
|
||||
|
||||
// Handle click events
|
||||
map.on('click', (e) => {
|
||||
onClick?.({ lat: e.lngLat.lat, lng: e.lngLat.lng }, e);
|
||||
});
|
||||
|
||||
map.on('dblclick', (e) => {
|
||||
onDoubleClick?.({ lat: e.lngLat.lat, lng: e.lngLat.lng }, e);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize MapLibre:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to initialize map'));
|
||||
}
|
||||
|
||||
return () => {
|
||||
// map.remove();
|
||||
mapRef.current = null;
|
||||
setIsLoaded(false);
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
setIsLoaded(false);
|
||||
}
|
||||
};
|
||||
}, [container]);
|
||||
}, [container]); // Only re-init if container changes
|
||||
|
||||
const setViewport = useCallback((newViewport: MapViewport) => {
|
||||
setViewportState(newViewport);
|
||||
onViewportChange?.(newViewport);
|
||||
// TODO: Update map instance
|
||||
}, [onViewportChange]);
|
||||
// Update viewport when props change (external control)
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !isLoaded) return;
|
||||
|
||||
const flyTo = useCallback((coordinate: Coordinate, zoom?: number) => {
|
||||
// TODO: Implement flyTo animation
|
||||
console.log('useMapInstance: flyTo', coordinate, zoom);
|
||||
}, []);
|
||||
const map = mapRef.current;
|
||||
const currentCenter = map.getCenter();
|
||||
const currentZoom = map.getZoom();
|
||||
const currentBearing = map.getBearing();
|
||||
const currentPitch = map.getPitch();
|
||||
|
||||
const fitBounds = useCallback((bounds: [[number, number], [number, number]]) => {
|
||||
// TODO: Implement fitBounds
|
||||
console.log('useMapInstance: fitBounds', bounds);
|
||||
}, []);
|
||||
// Only update if significantly different to avoid feedback loops
|
||||
const centerChanged =
|
||||
Math.abs(currentCenter.lat - initialViewport.center.lat) > 0.0001 ||
|
||||
Math.abs(currentCenter.lng - initialViewport.center.lng) > 0.0001;
|
||||
const zoomChanged = Math.abs(currentZoom - initialViewport.zoom) > 0.01;
|
||||
const bearingChanged = Math.abs(currentBearing - initialViewport.bearing) > 0.1;
|
||||
const pitchChanged = Math.abs(currentPitch - initialViewport.pitch) > 0.1;
|
||||
|
||||
if (centerChanged || zoomChanged || bearingChanged || pitchChanged) {
|
||||
map.jumpTo({
|
||||
center: [initialViewport.center.lng, initialViewport.center.lat],
|
||||
zoom: initialViewport.zoom,
|
||||
bearing: initialViewport.bearing,
|
||||
pitch: initialViewport.pitch,
|
||||
});
|
||||
}
|
||||
}, [initialViewport, isLoaded]);
|
||||
|
||||
const setViewport = useCallback(
|
||||
(newViewport: MapViewport) => {
|
||||
setViewportState(newViewport);
|
||||
onViewportChange?.(newViewport);
|
||||
|
||||
if (mapRef.current && isLoaded) {
|
||||
mapRef.current.jumpTo({
|
||||
center: [newViewport.center.lng, newViewport.center.lat],
|
||||
zoom: newViewport.zoom,
|
||||
bearing: newViewport.bearing,
|
||||
pitch: newViewport.pitch,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isLoaded, onViewportChange]
|
||||
);
|
||||
|
||||
const flyTo = useCallback(
|
||||
(coordinate: Coordinate, zoom?: number, options?: maplibregl.FlyToOptions) => {
|
||||
if (!mapRef.current || !isLoaded) return;
|
||||
|
||||
mapRef.current.flyTo({
|
||||
center: [coordinate.lng, coordinate.lat],
|
||||
zoom: zoom ?? mapRef.current.getZoom(),
|
||||
...options,
|
||||
});
|
||||
},
|
||||
[isLoaded]
|
||||
);
|
||||
|
||||
const fitBounds = useCallback(
|
||||
(bounds: [[number, number], [number, number]], options?: maplibregl.FitBoundsOptions) => {
|
||||
if (!mapRef.current || !isLoaded) return;
|
||||
|
||||
mapRef.current.fitBounds(bounds, {
|
||||
padding: 50,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
[isLoaded]
|
||||
);
|
||||
|
||||
const getMap = useCallback(() => mapRef.current, []);
|
||||
|
||||
const resize = useCallback(() => {
|
||||
if (mapRef.current && isLoaded) {
|
||||
mapRef.current.resize();
|
||||
}
|
||||
}, [isLoaded]);
|
||||
|
||||
return {
|
||||
isLoaded,
|
||||
error,
|
||||
viewport,
|
||||
setViewport,
|
||||
flyTo,
|
||||
fitBounds,
|
||||
getMap,
|
||||
resize,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
// Components
|
||||
export { MapCanvas } from './components/MapCanvas';
|
||||
export { CollaborativeMap } from './components/CollaborativeMap';
|
||||
export { RouteLayer } from './components/RouteLayer';
|
||||
export { WaypointMarker } from './components/WaypointMarker';
|
||||
export { LayerPanel } from './components/LayerPanel';
|
||||
|
|
@ -33,3 +34,25 @@ export { OptimizationService } from './services/OptimizationService';
|
|||
|
||||
// Types
|
||||
export type * from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Advanced Mapping Subsystems
|
||||
// =============================================================================
|
||||
|
||||
// Privacy-Preserving Location (zkGPS)
|
||||
export * as privacy from './privacy';
|
||||
|
||||
// Mycelial Signal Propagation Network
|
||||
export * as mycelium from './mycelium';
|
||||
|
||||
// Alternative Map Lens System
|
||||
export * as lenses from './lenses';
|
||||
|
||||
// Possibility Cones and Constraint Propagation
|
||||
export * as conics from './conics';
|
||||
|
||||
// zkGPS Location Games and Discovery System
|
||||
export * as discovery from './discovery';
|
||||
|
||||
// Real-Time Location Presence with Privacy Controls
|
||||
export * as presence from './presence';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,434 @@
|
|||
/**
|
||||
* Lens Blending and Transitions
|
||||
*
|
||||
* Handles smooth transitions between lenses and blending multiple
|
||||
* lenses together for hybrid visualizations.
|
||||
*/
|
||||
|
||||
import type {
|
||||
TransformedPoint,
|
||||
LensConfig,
|
||||
LensState,
|
||||
LensTransition,
|
||||
EasingFunction,
|
||||
DataPoint,
|
||||
} from './types';
|
||||
import { transformPoint } from './transforms';
|
||||
|
||||
// =============================================================================
|
||||
// Easing Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Easing function implementations
|
||||
*/
|
||||
export const EASING_FUNCTIONS: Record<EasingFunction, (t: number) => number> = {
|
||||
linear: (t) => t,
|
||||
|
||||
'ease-in': (t) => t * t,
|
||||
|
||||
'ease-out': (t) => t * (2 - t),
|
||||
|
||||
'ease-in-out': (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
||||
|
||||
spring: (t) => {
|
||||
const c4 = (2 * Math.PI) / 3;
|
||||
return t === 0
|
||||
? 0
|
||||
: t === 1
|
||||
? 1
|
||||
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
||||
},
|
||||
|
||||
bounce: (t) => {
|
||||
const n1 = 7.5625;
|
||||
const d1 = 2.75;
|
||||
|
||||
if (t < 1 / d1) {
|
||||
return n1 * t * t;
|
||||
} else if (t < 2 / d1) {
|
||||
return n1 * (t -= 1.5 / d1) * t + 0.75;
|
||||
} else if (t < 2.5 / d1) {
|
||||
return n1 * (t -= 2.25 / d1) * t + 0.9375;
|
||||
} else {
|
||||
return n1 * (t -= 2.625 / d1) * t + 0.984375;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply easing to a value
|
||||
*/
|
||||
export function applyEasing(t: number, easing: EasingFunction): number {
|
||||
const fn = EASING_FUNCTIONS[easing] ?? EASING_FUNCTIONS.linear;
|
||||
return fn(Math.max(0, Math.min(1, t)));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Transition Management
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a new lens transition
|
||||
*/
|
||||
export function createTransition(
|
||||
from: LensConfig[],
|
||||
to: LensConfig[],
|
||||
duration: number = 500,
|
||||
easing: EasingFunction = 'ease-in-out'
|
||||
): LensTransition {
|
||||
return {
|
||||
id: `transition-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
from: from.map((c) => ({ ...c })),
|
||||
to: to.map((c) => ({ ...c })),
|
||||
duration,
|
||||
startTime: Date.now(),
|
||||
easing,
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transition progress
|
||||
*/
|
||||
export function updateTransition(transition: LensTransition): LensTransition {
|
||||
const elapsed = Date.now() - transition.startTime;
|
||||
const rawProgress = Math.min(1, elapsed / transition.duration);
|
||||
const progress = applyEasing(rawProgress, transition.easing);
|
||||
|
||||
return {
|
||||
...transition,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a transition is complete
|
||||
*/
|
||||
export function isTransitionComplete(transition: LensTransition): boolean {
|
||||
return Date.now() - transition.startTime >= transition.duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interpolated lens configs during transition
|
||||
*/
|
||||
export function getTransitionConfigs(transition: LensTransition): LensConfig[] {
|
||||
const { from, to, progress } = transition;
|
||||
|
||||
// Find matching lens types and interpolate
|
||||
const result: LensConfig[] = [];
|
||||
|
||||
// Handle lenses in both from and to
|
||||
const fromTypes = new Set(from.map((c) => c.type));
|
||||
const toTypes = new Set(to.map((c) => c.type));
|
||||
|
||||
// Lenses in both: interpolate weight
|
||||
for (const type of fromTypes) {
|
||||
if (toTypes.has(type)) {
|
||||
const fromLens = from.find((c) => c.type === type)!;
|
||||
const toLens = to.find((c) => c.type === type)!;
|
||||
|
||||
result.push(interpolateLensConfig(fromLens, toLens, progress));
|
||||
} else {
|
||||
// Fading out
|
||||
const fromLens = from.find((c) => c.type === type)!;
|
||||
result.push({
|
||||
...fromLens,
|
||||
weight: fromLens.weight * (1 - progress),
|
||||
active: progress < 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Lenses only in to: fading in
|
||||
for (const type of toTypes) {
|
||||
if (!fromTypes.has(type)) {
|
||||
const toLens = to.find((c) => c.type === type)!;
|
||||
result.push({
|
||||
...toLens,
|
||||
weight: toLens.weight * progress,
|
||||
active: progress > 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate between two lens configs of the same type
|
||||
*/
|
||||
function interpolateLensConfig(
|
||||
from: LensConfig,
|
||||
to: LensConfig,
|
||||
t: number
|
||||
): LensConfig {
|
||||
// Base interpolation
|
||||
const base = {
|
||||
...from,
|
||||
weight: lerp(from.weight, to.weight, t),
|
||||
active: t > 0.5 ? to.active : from.active,
|
||||
};
|
||||
|
||||
// Type-specific interpolation
|
||||
switch (from.type) {
|
||||
case 'geographic':
|
||||
if (to.type === 'geographic') {
|
||||
return {
|
||||
...base,
|
||||
type: 'geographic',
|
||||
center: {
|
||||
lat: lerp(from.center.lat, to.center.lat, t),
|
||||
lng: lerp(from.center.lng, to.center.lng, t),
|
||||
},
|
||||
zoom: lerp(from.zoom, to.zoom, t),
|
||||
bearing: lerpAngle(from.bearing, to.bearing, t),
|
||||
pitch: lerp(from.pitch, to.pitch, t),
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'temporal':
|
||||
if (to.type === 'temporal') {
|
||||
return {
|
||||
...base,
|
||||
type: 'temporal',
|
||||
timeRange: {
|
||||
start: lerp(from.timeRange.start, to.timeRange.start, t),
|
||||
end: lerp(from.timeRange.end, to.timeRange.end, t),
|
||||
},
|
||||
currentTime: lerp(from.currentTime, to.currentTime, t),
|
||||
timeScale: lerp(from.timeScale, to.timeScale, t),
|
||||
playing: t > 0.5 ? to.playing : from.playing,
|
||||
playbackSpeed: lerp(from.playbackSpeed, to.playbackSpeed, t),
|
||||
groupBy: t > 0.5 ? to.groupBy : from.groupBy,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'attention':
|
||||
if (to.type === 'attention') {
|
||||
return {
|
||||
...base,
|
||||
type: 'attention',
|
||||
decayRate: lerp(from.decayRate, to.decayRate, t),
|
||||
minAttention: lerp(from.minAttention, to.minAttention, t),
|
||||
colorGradient: {
|
||||
low: interpolateColor(from.colorGradient.low, to.colorGradient.low, t),
|
||||
medium: interpolateColor(from.colorGradient.medium, to.colorGradient.medium, t),
|
||||
high: interpolateColor(from.colorGradient.high, to.colorGradient.high, t),
|
||||
},
|
||||
showHeatmap: t > 0.5 ? to.showHeatmap : from.showHeatmap,
|
||||
heatmapRadius: lerp(from.heatmapRadius, to.heatmapRadius, t),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return base as LensConfig;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Point Blending
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Blend multiple transformed points into one
|
||||
*/
|
||||
export function blendPoints(
|
||||
points: TransformedPoint[],
|
||||
weights: number[]
|
||||
): TransformedPoint {
|
||||
if (points.length === 0) {
|
||||
return {
|
||||
id: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 0,
|
||||
opacity: 0,
|
||||
visible: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
return points[0];
|
||||
}
|
||||
|
||||
// Normalize weights
|
||||
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||||
const normalizedWeights = weights.map((w) => w / totalWeight);
|
||||
|
||||
// Weighted average of all properties
|
||||
let x = 0,
|
||||
y = 0,
|
||||
z = 0,
|
||||
size = 0,
|
||||
opacity = 0;
|
||||
let visible = false;
|
||||
let color: string | undefined;
|
||||
let colorR = 0,
|
||||
colorG = 0,
|
||||
colorB = 0,
|
||||
colorWeight = 0;
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const w = normalizedWeights[i];
|
||||
|
||||
x += p.x * w;
|
||||
y += p.y * w;
|
||||
z += (p.z ?? 0) * w;
|
||||
size += p.size * w;
|
||||
opacity += p.opacity * w;
|
||||
visible = visible || p.visible;
|
||||
|
||||
if (p.color) {
|
||||
const c = parseInt(p.color.slice(1), 16);
|
||||
colorR += ((c >> 16) & 255) * w;
|
||||
colorG += ((c >> 8) & 255) * w;
|
||||
colorB += (c & 255) * w;
|
||||
colorWeight += w;
|
||||
}
|
||||
}
|
||||
|
||||
if (colorWeight > 0) {
|
||||
const r = Math.round(colorR / colorWeight);
|
||||
const g = Math.round(colorG / colorWeight);
|
||||
const b = Math.round(colorB / colorWeight);
|
||||
color = `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: points[0].id,
|
||||
x,
|
||||
y,
|
||||
z: z !== 0 ? z : undefined,
|
||||
size,
|
||||
opacity,
|
||||
color,
|
||||
visible,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a point through multiple lenses and blend
|
||||
*/
|
||||
export function transformAndBlend(
|
||||
point: DataPoint,
|
||||
lenses: LensConfig[],
|
||||
viewport: LensState['viewport']
|
||||
): TransformedPoint {
|
||||
// Get active lenses with non-zero weights
|
||||
const activeLenses = lenses.filter((l) => l.active && l.weight > 0);
|
||||
|
||||
if (activeLenses.length === 0) {
|
||||
return {
|
||||
id: point.id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 0,
|
||||
opacity: 0,
|
||||
visible: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (activeLenses.length === 1) {
|
||||
const transformed = transformPoint(point, activeLenses[0], viewport);
|
||||
return {
|
||||
...transformed,
|
||||
opacity: transformed.opacity * activeLenses[0].weight,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform through each lens
|
||||
const transformedPoints: TransformedPoint[] = [];
|
||||
const weights: number[] = [];
|
||||
|
||||
for (const lens of activeLenses) {
|
||||
const transformed = transformPoint(point, lens, viewport);
|
||||
transformedPoints.push(transformed);
|
||||
weights.push(lens.weight);
|
||||
}
|
||||
|
||||
// Blend results
|
||||
return blendPoints(transformedPoints, weights);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Interpolation Utilities
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Linear interpolation
|
||||
*/
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Angle interpolation (handles wraparound)
|
||||
*/
|
||||
function lerpAngle(a: number, b: number, t: number): number {
|
||||
// Normalize to -180 to 180
|
||||
let diff = ((b - a + 180) % 360) - 180;
|
||||
if (diff < -180) diff += 360;
|
||||
return a + diff * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color interpolation
|
||||
*/
|
||||
function interpolateColor(color1: string, color2: string, t: number): string {
|
||||
const c1 = parseInt(color1.slice(1), 16);
|
||||
const c2 = parseInt(color2.slice(1), 16);
|
||||
|
||||
const r = Math.round(lerp((c1 >> 16) & 255, (c2 >> 16) & 255, t));
|
||||
const g = Math.round(lerp((c1 >> 8) & 255, (c2 >> 8) & 255, t));
|
||||
const b = Math.round(lerp(c1 & 255, c2 & 255, t));
|
||||
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Transition Presets
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Quick transition (for responsive feel)
|
||||
*/
|
||||
export const QUICK_TRANSITION: Pick<LensTransition, 'duration' | 'easing'> = {
|
||||
duration: 200,
|
||||
easing: 'ease-out',
|
||||
};
|
||||
|
||||
/**
|
||||
* Smooth transition (for cinematic feel)
|
||||
*/
|
||||
export const SMOOTH_TRANSITION: Pick<LensTransition, 'duration' | 'easing'> = {
|
||||
duration: 500,
|
||||
easing: 'ease-in-out',
|
||||
};
|
||||
|
||||
/**
|
||||
* Slow transition (for dramatic reveal)
|
||||
*/
|
||||
export const SLOW_TRANSITION: Pick<LensTransition, 'duration' | 'easing'> = {
|
||||
duration: 1000,
|
||||
easing: 'ease-in-out',
|
||||
};
|
||||
|
||||
/**
|
||||
* Bouncy transition (for playful interactions)
|
||||
*/
|
||||
export const BOUNCY_TRANSITION: Pick<LensTransition, 'duration' | 'easing'> = {
|
||||
duration: 600,
|
||||
easing: 'bounce',
|
||||
};
|
||||
|
||||
/**
|
||||
* Spring transition (for organic feel)
|
||||
*/
|
||||
export const SPRING_TRANSITION: Pick<LensTransition, 'duration' | 'easing'> = {
|
||||
duration: 800,
|
||||
easing: 'spring',
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Alternative Map Lens System
|
||||
*
|
||||
* Multiple "lens" views that project different data dimensions onto
|
||||
* the canvas coordinate space. The same underlying data can be viewed
|
||||
* through different lenses to reveal different patterns.
|
||||
*
|
||||
* Available lenses:
|
||||
* - Geographic: Traditional OSM basemap, physical locations
|
||||
* - Temporal: Time as X-axis, events as nodes, time-scrubbing
|
||||
* - Attention: Heatmap of collective focus
|
||||
* - Incentive: Value gradients, token flows
|
||||
* - Relational: Social graph topology
|
||||
* - Possibility: Branching futures, what-if scenarios
|
||||
*/
|
||||
|
||||
// Core types
|
||||
export * from './types';
|
||||
|
||||
// Transforms
|
||||
export {
|
||||
transformGeographic,
|
||||
transformTemporal,
|
||||
transformAttention,
|
||||
transformIncentive,
|
||||
transformRelational,
|
||||
transformPossibility,
|
||||
getTransformForLens,
|
||||
transformPoint,
|
||||
computeForceDirectedLayout,
|
||||
} from './transforms';
|
||||
|
||||
// Blending and transitions
|
||||
export {
|
||||
EASING_FUNCTIONS,
|
||||
applyEasing,
|
||||
createTransition,
|
||||
updateTransition,
|
||||
isTransitionComplete,
|
||||
getTransitionConfigs,
|
||||
blendPoints,
|
||||
transformAndBlend,
|
||||
QUICK_TRANSITION,
|
||||
SMOOTH_TRANSITION,
|
||||
SLOW_TRANSITION,
|
||||
BOUNCY_TRANSITION,
|
||||
SPRING_TRANSITION,
|
||||
} from './blending';
|
||||
|
||||
// Manager
|
||||
export {
|
||||
LensManager,
|
||||
createLensManager,
|
||||
DEFAULT_LENS_MANAGER_CONFIG,
|
||||
type LensManagerConfig,
|
||||
} from './manager';
|
||||
|
|
@ -0,0 +1,637 @@
|
|||
/**
|
||||
* Lens Manager
|
||||
*
|
||||
* Central coordinator for the lens system. Manages lens states,
|
||||
* transitions, and point transformations.
|
||||
*/
|
||||
|
||||
import type {
|
||||
LensConfig,
|
||||
LensState,
|
||||
LensTransition,
|
||||
LensType,
|
||||
DataPoint,
|
||||
TransformedPoint,
|
||||
LensEvent,
|
||||
LensEventListener,
|
||||
TemporalPortal,
|
||||
EasingFunction,
|
||||
} from './types';
|
||||
import { DEFAULT_LENSES } from './types';
|
||||
import {
|
||||
createTransition,
|
||||
updateTransition,
|
||||
isTransitionComplete,
|
||||
getTransitionConfigs,
|
||||
transformAndBlend,
|
||||
SMOOTH_TRANSITION,
|
||||
} from './blending';
|
||||
|
||||
// =============================================================================
|
||||
// Lens Manager
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for the lens manager
|
||||
*/
|
||||
export interface LensManagerConfig {
|
||||
/** Initial lenses */
|
||||
initialLenses: LensConfig[];
|
||||
|
||||
/** Viewport dimensions */
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/** Default transition settings */
|
||||
defaultTransition: {
|
||||
duration: number;
|
||||
easing: EasingFunction;
|
||||
};
|
||||
|
||||
/** Temporal lens playback settings */
|
||||
temporalPlayback: {
|
||||
/** Update interval during playback (ms) */
|
||||
updateInterval: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
export const DEFAULT_LENS_MANAGER_CONFIG: LensManagerConfig = {
|
||||
initialLenses: DEFAULT_LENSES,
|
||||
viewport: { width: 1000, height: 800 },
|
||||
defaultTransition: SMOOTH_TRANSITION,
|
||||
temporalPlayback: { updateInterval: 50 },
|
||||
};
|
||||
|
||||
/**
|
||||
* The Lens Manager
|
||||
*/
|
||||
export class LensManager {
|
||||
private config: LensManagerConfig;
|
||||
private state: LensState;
|
||||
private listeners: Set<LensEventListener> = new Set();
|
||||
private dataPoints: Map<string, DataPoint> = new Map();
|
||||
private temporalPortals: Map<string, TemporalPortal> = new Map();
|
||||
private playbackTimer?: ReturnType<typeof setInterval>;
|
||||
private transitionFrame?: number;
|
||||
|
||||
constructor(config: Partial<LensManagerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_LENS_MANAGER_CONFIG, ...config };
|
||||
|
||||
// Initialize state
|
||||
this.state = {
|
||||
activeLenses: this.config.initialLenses.filter((l) => l.active),
|
||||
viewport: {
|
||||
width: this.config.viewport.width,
|
||||
height: this.config.viewport.height,
|
||||
centerX: this.config.viewport.width / 2,
|
||||
centerY: this.config.viewport.height / 2,
|
||||
scale: 1,
|
||||
},
|
||||
transformedPoints: new Map(),
|
||||
lastUpdate: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lens Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get all lenses
|
||||
*/
|
||||
getAllLenses(): LensConfig[] {
|
||||
return [...this.config.initialLenses];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active lenses
|
||||
*/
|
||||
getActiveLenses(): LensConfig[] {
|
||||
return [...this.state.activeLenses];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a lens by type
|
||||
*/
|
||||
getLens(type: LensType): LensConfig | undefined {
|
||||
return this.config.initialLenses.find((l) => l.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a lens
|
||||
*/
|
||||
activateLens(
|
||||
type: LensType,
|
||||
options: {
|
||||
exclusive?: boolean;
|
||||
transition?: Partial<LensTransition>;
|
||||
} = {}
|
||||
): void {
|
||||
const lens = this.getLens(type);
|
||||
if (!lens) return;
|
||||
|
||||
const { exclusive = false, transition } = options;
|
||||
|
||||
// Determine target state
|
||||
let targetLenses: LensConfig[];
|
||||
|
||||
if (exclusive) {
|
||||
// Only this lens active
|
||||
targetLenses = [{ ...lens, active: true, weight: 1 }];
|
||||
} else {
|
||||
// Add to existing
|
||||
const existing = this.state.activeLenses.filter((l) => l.type !== type);
|
||||
targetLenses = [...existing, { ...lens, active: true }];
|
||||
|
||||
// Normalize weights
|
||||
const totalWeight = targetLenses.reduce((s, l) => s + l.weight, 0);
|
||||
if (totalWeight > 0) {
|
||||
targetLenses = targetLenses.map((l) => ({
|
||||
...l,
|
||||
weight: l.weight / totalWeight,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create transition
|
||||
this.startTransition(targetLenses, transition);
|
||||
|
||||
this.emit({ type: 'lens:activated', lens: { ...lens, active: true } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a lens
|
||||
*/
|
||||
deactivateLens(type: LensType): void {
|
||||
const remaining = this.state.activeLenses.filter((l) => l.type !== type);
|
||||
|
||||
if (remaining.length === 0) {
|
||||
// Keep at least one lens
|
||||
const firstLens = this.config.initialLenses[0];
|
||||
if (firstLens) {
|
||||
remaining.push({ ...firstLens, active: true, weight: 1 });
|
||||
}
|
||||
} else {
|
||||
// Normalize weights
|
||||
const totalWeight = remaining.reduce((s, l) => s + l.weight, 0);
|
||||
remaining.forEach((l) => {
|
||||
l.weight = l.weight / totalWeight;
|
||||
});
|
||||
}
|
||||
|
||||
this.startTransition(remaining);
|
||||
|
||||
this.emit({ type: 'lens:deactivated', lensType: type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set lens weight for blending
|
||||
*/
|
||||
setLensWeight(type: LensType, weight: number): void {
|
||||
const lens = this.state.activeLenses.find((l) => l.type === type);
|
||||
if (!lens) return;
|
||||
|
||||
lens.weight = Math.max(0, Math.min(1, weight));
|
||||
|
||||
// Normalize weights
|
||||
const totalWeight = this.state.activeLenses.reduce((s, l) => s + l.weight, 0);
|
||||
if (totalWeight > 0) {
|
||||
this.state.activeLenses.forEach((l) => {
|
||||
l.weight = l.weight / totalWeight;
|
||||
});
|
||||
}
|
||||
|
||||
this.updateTransformedPoints();
|
||||
this.emit({ type: 'lens:updated', lens });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update lens configuration
|
||||
*/
|
||||
updateLens(type: LensType, updates: Partial<LensConfig>): void {
|
||||
const lens = this.state.activeLenses.find((l) => l.type === type);
|
||||
if (!lens) return;
|
||||
|
||||
Object.assign(lens, updates);
|
||||
this.updateTransformedPoints();
|
||||
this.emit({ type: 'lens:updated', lens });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Transitions
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Start a transition to new lens configuration
|
||||
*/
|
||||
private startTransition(
|
||||
targetLenses: LensConfig[],
|
||||
options?: Partial<LensTransition>
|
||||
): void {
|
||||
// Cancel any existing transition
|
||||
if (this.transitionFrame) {
|
||||
cancelAnimationFrame(this.transitionFrame);
|
||||
}
|
||||
|
||||
const transition = createTransition(
|
||||
this.state.activeLenses,
|
||||
targetLenses,
|
||||
options?.duration ?? this.config.defaultTransition.duration,
|
||||
options?.easing ?? this.config.defaultTransition.easing
|
||||
);
|
||||
|
||||
this.state.transition = transition;
|
||||
this.emit({ type: 'transition:started', transition });
|
||||
|
||||
// Run transition loop
|
||||
this.runTransition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run transition animation frame
|
||||
*/
|
||||
private runTransition(): void {
|
||||
if (!this.state.transition) return;
|
||||
|
||||
const transition = updateTransition(this.state.transition);
|
||||
this.state.transition = transition;
|
||||
|
||||
// Get interpolated lens configs
|
||||
this.state.activeLenses = getTransitionConfigs(transition);
|
||||
|
||||
// Update points
|
||||
this.updateTransformedPoints();
|
||||
|
||||
this.emit({ type: 'transition:progress', transition });
|
||||
|
||||
if (isTransitionComplete(transition)) {
|
||||
// Transition complete
|
||||
this.state.activeLenses = transition.to.map((l) => ({ ...l }));
|
||||
this.state.transition = undefined;
|
||||
this.emit({ type: 'transition:completed', transition });
|
||||
this.updateTransformedPoints();
|
||||
} else {
|
||||
// Continue
|
||||
this.transitionFrame = requestAnimationFrame(() => this.runTransition());
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Data Points
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Add or update a data point
|
||||
*/
|
||||
setDataPoint(point: DataPoint): void {
|
||||
this.dataPoints.set(point.id, point);
|
||||
this.updateTransformedPoint(point);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple data points
|
||||
*/
|
||||
setDataPoints(points: DataPoint[]): void {
|
||||
for (const point of points) {
|
||||
this.dataPoints.set(point.id, point);
|
||||
}
|
||||
this.updateTransformedPoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a data point
|
||||
*/
|
||||
removeDataPoint(id: string): void {
|
||||
this.dataPoints.delete(id);
|
||||
this.state.transformedPoints.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a transformed point
|
||||
*/
|
||||
getTransformedPoint(id: string): TransformedPoint | undefined {
|
||||
return this.state.transformedPoints.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transformed points
|
||||
*/
|
||||
getAllTransformedPoints(): TransformedPoint[] {
|
||||
return Array.from(this.state.transformedPoints.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visible transformed points
|
||||
*/
|
||||
getVisiblePoints(): TransformedPoint[] {
|
||||
return Array.from(this.state.transformedPoints.values()).filter(
|
||||
(p) => p.visible
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transformed point for a single data point
|
||||
*/
|
||||
private updateTransformedPoint(point: DataPoint): void {
|
||||
const transformed = transformAndBlend(
|
||||
point,
|
||||
this.state.activeLenses,
|
||||
this.state.viewport
|
||||
);
|
||||
this.state.transformedPoints.set(point.id, transformed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all transformed points
|
||||
*/
|
||||
private updateTransformedPoints(): void {
|
||||
for (const point of this.dataPoints.values()) {
|
||||
this.updateTransformedPoint(point);
|
||||
}
|
||||
this.state.lastUpdate = Date.now();
|
||||
this.emit({ type: 'points:transformed', count: this.dataPoints.size });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Viewport
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Update viewport dimensions
|
||||
*/
|
||||
setViewport(
|
||||
viewport: Partial<LensState['viewport']>
|
||||
): void {
|
||||
Object.assign(this.state.viewport, viewport);
|
||||
|
||||
// Update center if dimensions changed
|
||||
if (viewport.width !== undefined || viewport.height !== undefined) {
|
||||
this.state.viewport.centerX =
|
||||
viewport.centerX ?? this.state.viewport.width / 2;
|
||||
this.state.viewport.centerY =
|
||||
viewport.centerY ?? this.state.viewport.height / 2;
|
||||
}
|
||||
|
||||
this.updateTransformedPoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pan viewport
|
||||
*/
|
||||
pan(dx: number, dy: number): void {
|
||||
this.state.viewport.centerX -= dx;
|
||||
this.state.viewport.centerY -= dy;
|
||||
this.updateTransformedPoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom viewport
|
||||
*/
|
||||
zoom(factor: number, centerX?: number, centerY?: number): void {
|
||||
const cx = centerX ?? this.state.viewport.centerX;
|
||||
const cy = centerY ?? this.state.viewport.centerY;
|
||||
|
||||
// Zoom toward point
|
||||
this.state.viewport.centerX += (cx - this.state.viewport.centerX) * (1 - factor);
|
||||
this.state.viewport.centerY += (cy - this.state.viewport.centerY) * (1 - factor);
|
||||
this.state.viewport.scale *= factor;
|
||||
|
||||
this.updateTransformedPoints();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Temporal Lens Controls
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Scrub to a specific time
|
||||
*/
|
||||
scrubToTime(time: number): void {
|
||||
const temporal = this.state.activeLenses.find(
|
||||
(l) => l.type === 'temporal'
|
||||
) as import('./types').TemporalLensConfig | undefined;
|
||||
|
||||
if (!temporal) return;
|
||||
|
||||
temporal.currentTime = time;
|
||||
this.updateTransformedPoints();
|
||||
this.emit({ type: 'temporal:scrub', time });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start temporal playback
|
||||
*/
|
||||
play(): void {
|
||||
const temporal = this.state.activeLenses.find(
|
||||
(l) => l.type === 'temporal'
|
||||
) as import('./types').TemporalLensConfig | undefined;
|
||||
|
||||
if (!temporal) return;
|
||||
|
||||
temporal.playing = true;
|
||||
|
||||
if (this.playbackTimer) {
|
||||
clearInterval(this.playbackTimer);
|
||||
}
|
||||
|
||||
this.playbackTimer = setInterval(() => {
|
||||
if (!temporal.playing) {
|
||||
clearInterval(this.playbackTimer);
|
||||
return;
|
||||
}
|
||||
|
||||
const step =
|
||||
this.config.temporalPlayback.updateInterval * temporal.playbackSpeed;
|
||||
temporal.currentTime = Math.min(
|
||||
temporal.timeRange.end,
|
||||
temporal.currentTime + step
|
||||
);
|
||||
|
||||
if (temporal.currentTime >= temporal.timeRange.end) {
|
||||
this.pause();
|
||||
}
|
||||
|
||||
this.updateTransformedPoints();
|
||||
this.emit({ type: 'temporal:scrub', time: temporal.currentTime });
|
||||
}, this.config.temporalPlayback.updateInterval);
|
||||
|
||||
this.emit({ type: 'temporal:play' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause temporal playback
|
||||
*/
|
||||
pause(): void {
|
||||
const temporal = this.state.activeLenses.find(
|
||||
(l) => l.type === 'temporal'
|
||||
) as import('./types').TemporalLensConfig | undefined;
|
||||
|
||||
if (!temporal) return;
|
||||
|
||||
temporal.playing = false;
|
||||
|
||||
if (this.playbackTimer) {
|
||||
clearInterval(this.playbackTimer);
|
||||
this.playbackTimer = undefined;
|
||||
}
|
||||
|
||||
this.emit({ type: 'temporal:pause' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set playback speed
|
||||
*/
|
||||
setPlaybackSpeed(speed: number): void {
|
||||
const temporal = this.state.activeLenses.find(
|
||||
(l) => l.type === 'temporal'
|
||||
) as import('./types').TemporalLensConfig | undefined;
|
||||
|
||||
if (!temporal) return;
|
||||
|
||||
temporal.playbackSpeed = speed;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Temporal Portals
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create a temporal portal
|
||||
*/
|
||||
createPortal(
|
||||
location: { lat: number; lng: number },
|
||||
targetTime: number,
|
||||
position?: { x: number; y: number }
|
||||
): TemporalPortal {
|
||||
const portal: TemporalPortal = {
|
||||
id: `portal-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
location,
|
||||
position: position ?? {
|
||||
x: this.state.viewport.centerX,
|
||||
y: this.state.viewport.centerY,
|
||||
},
|
||||
targetTime,
|
||||
radius: 100,
|
||||
active: true,
|
||||
};
|
||||
|
||||
this.temporalPortals.set(portal.id, portal);
|
||||
return portal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a temporal portal
|
||||
*/
|
||||
removePortal(id: string): void {
|
||||
this.temporalPortals.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all temporal portals
|
||||
*/
|
||||
getPortals(): TemporalPortal[] {
|
||||
return Array.from(this.temporalPortals.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point is within a portal
|
||||
*/
|
||||
isInPortal(x: number, y: number): TemporalPortal | null {
|
||||
for (const portal of this.temporalPortals.values()) {
|
||||
if (!portal.active) continue;
|
||||
|
||||
const dx = x - portal.position.x;
|
||||
const dy = y - portal.position.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist <= portal.radius) {
|
||||
return portal;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// State Access
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getState(): LensState {
|
||||
return {
|
||||
...this.state,
|
||||
activeLenses: [...this.state.activeLenses],
|
||||
transformedPoints: new Map(this.state.transformedPoints),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if transition is in progress
|
||||
*/
|
||||
isTransitioning(): boolean {
|
||||
return this.state.transition !== undefined;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Event System
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(listener: LensEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
*/
|
||||
private emit(event: LensEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('Error in lens event listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Cleanup
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.playbackTimer) {
|
||||
clearInterval(this.playbackTimer);
|
||||
}
|
||||
if (this.transitionFrame) {
|
||||
cancelAnimationFrame(this.transitionFrame);
|
||||
}
|
||||
this.listeners.clear();
|
||||
this.dataPoints.clear();
|
||||
this.temporalPortals.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a new lens manager
|
||||
*/
|
||||
export function createLensManager(
|
||||
config?: Partial<LensManagerConfig>
|
||||
): LensManager {
|
||||
return new LensManager(config);
|
||||
}
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
/**
|
||||
* Lens Coordinate Transforms
|
||||
*
|
||||
* Transform functions for projecting data through different lenses.
|
||||
* Each lens type has its own transformation logic.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DataPoint,
|
||||
TransformedPoint,
|
||||
LensConfig,
|
||||
LensState,
|
||||
GeographicLensConfig,
|
||||
TemporalLensConfig,
|
||||
AttentionLensConfig,
|
||||
IncentiveLensConfig,
|
||||
RelationalLensConfig,
|
||||
PossibilityLensConfig,
|
||||
LensTransformFn,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Geographic Transform
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Transform a point using the geographic lens
|
||||
* Projects lat/lng to Web Mercator canvas coordinates
|
||||
*/
|
||||
export function transformGeographic(
|
||||
point: DataPoint,
|
||||
config: GeographicLensConfig,
|
||||
viewport: LensState['viewport']
|
||||
): TransformedPoint {
|
||||
if (!point.geo) {
|
||||
return createInvisiblePoint(point.id);
|
||||
}
|
||||
|
||||
// Web Mercator projection
|
||||
const { lat, lng } = point.geo;
|
||||
const { center, zoom } = config;
|
||||
|
||||
// Convert to tile coordinates
|
||||
const tileSize = 256;
|
||||
const scale = Math.pow(2, zoom);
|
||||
|
||||
// Center in tile space
|
||||
const centerX = ((center.lng + 180) / 360) * scale * tileSize;
|
||||
const centerY =
|
||||
((1 -
|
||||
Math.log(
|
||||
Math.tan((center.lat * Math.PI) / 180) +
|
||||
1 / Math.cos((center.lat * Math.PI) / 180)
|
||||
) /
|
||||
Math.PI) /
|
||||
2) *
|
||||
scale *
|
||||
tileSize;
|
||||
|
||||
// Point in tile space
|
||||
const pointX = ((lng + 180) / 360) * scale * tileSize;
|
||||
const pointY =
|
||||
((1 -
|
||||
Math.log(
|
||||
Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)
|
||||
) /
|
||||
Math.PI) /
|
||||
2) *
|
||||
scale *
|
||||
tileSize;
|
||||
|
||||
// Offset from center
|
||||
const offsetX = pointX - centerX;
|
||||
const offsetY = pointY - centerY;
|
||||
|
||||
// Apply rotation (bearing)
|
||||
const bearingRad = (config.bearing * Math.PI) / 180;
|
||||
const rotatedX = offsetX * Math.cos(bearingRad) - offsetY * Math.sin(bearingRad);
|
||||
const rotatedY = offsetX * Math.sin(bearingRad) + offsetY * Math.cos(bearingRad);
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const x = viewport.centerX + rotatedX * viewport.scale;
|
||||
const y = viewport.centerY + rotatedY * viewport.scale;
|
||||
|
||||
// Check if in viewport
|
||||
const padding = 50;
|
||||
const visible =
|
||||
x >= -padding &&
|
||||
x <= viewport.width + padding &&
|
||||
y >= -padding &&
|
||||
y <= viewport.height + padding;
|
||||
|
||||
return {
|
||||
id: point.id,
|
||||
x,
|
||||
y,
|
||||
size: 0.5,
|
||||
opacity: 1,
|
||||
visible,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Temporal Transform
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Transform a point using the temporal lens
|
||||
* X-axis = time, Y-axis = grouped by type/owner
|
||||
*/
|
||||
export function transformTemporal(
|
||||
point: DataPoint,
|
||||
config: TemporalLensConfig,
|
||||
viewport: LensState['viewport']
|
||||
): TransformedPoint {
|
||||
if (!point.timestamp) {
|
||||
return createInvisiblePoint(point.id);
|
||||
}
|
||||
|
||||
const { timeRange, timeScale, groupBy } = config;
|
||||
|
||||
// Check if in time range
|
||||
if (point.timestamp < timeRange.start || point.timestamp > timeRange.end) {
|
||||
return createInvisiblePoint(point.id);
|
||||
}
|
||||
|
||||
// X position based on time
|
||||
const timeOffset = point.timestamp - timeRange.start;
|
||||
const x = viewport.centerX - viewport.width / 2 + timeOffset * timeScale;
|
||||
|
||||
// Y position based on grouping
|
||||
let y = viewport.height / 2;
|
||||
|
||||
if (groupBy !== 'none') {
|
||||
// Hash the group key to a vertical position
|
||||
const groupKey = getGroupKey(point, groupBy);
|
||||
const hash = simpleHash(groupKey);
|
||||
const lanes = 10;
|
||||
const lane = hash % lanes;
|
||||
const laneHeight = viewport.height / lanes;
|
||||
y = lane * laneHeight + laneHeight / 2;
|
||||
}
|
||||
|
||||
// Size based on recency to current time
|
||||
const distanceFromCurrent = Math.abs(point.timestamp - config.currentTime);
|
||||
const maxDistance = timeRange.end - timeRange.start;
|
||||
const recencyFactor = 1 - distanceFromCurrent / maxDistance;
|
||||
const size = 0.3 + recencyFactor * 0.7;
|
||||
|
||||
// Opacity based on distance from current time
|
||||
const opacity = 0.3 + recencyFactor * 0.7;
|
||||
|
||||
const visible = x >= 0 && x <= viewport.width;
|
||||
|
||||
return {
|
||||
id: point.id,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
opacity,
|
||||
visible,
|
||||
};
|
||||
}
|
||||
|
||||
function getGroupKey(point: DataPoint, groupBy: string): string {
|
||||
switch (groupBy) {
|
||||
case 'type':
|
||||
return String(point.attributes.type ?? 'unknown');
|
||||
case 'owner':
|
||||
return String(point.attributes.owner ?? 'unknown');
|
||||
case 'location':
|
||||
if (point.geo) {
|
||||
// Rough location bucket
|
||||
return `${Math.floor(point.geo.lat)},${Math.floor(point.geo.lng)}`;
|
||||
}
|
||||
return 'no-location';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Attention Transform
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Transform a point using the attention lens
|
||||
* Position preserved, size/opacity based on attention
|
||||
*/
|
||||
export function transformAttention(
|
||||
point: DataPoint,
|
||||
config: AttentionLensConfig,
|
||||
viewport: LensState['viewport']
|
||||
): TransformedPoint {
|
||||
// Need either geo or a canvas position
|
||||
if (!point.geo && !point.attributes.canvasPosition) {
|
||||
return createInvisiblePoint(point.id);
|
||||
}
|
||||
|
||||
// Get base position (from geographic projection if geo exists)
|
||||
let x: number, y: number;
|
||||
|
||||
if (point.geo) {
|
||||
// Simple equirectangular for attention lens
|
||||
const { lat, lng } = point.geo;
|
||||
x = viewport.centerX + (lng / 180) * (viewport.width / 2);
|
||||
y = viewport.centerY - (lat / 90) * (viewport.height / 2);
|
||||
} else {
|
||||
const pos = point.attributes.canvasPosition as { x: number; y: number };
|
||||
x = pos.x;
|
||||
y = pos.y;
|
||||
}
|
||||
|
||||
// Calculate decayed attention
|
||||
const baseAttention = point.attention ?? 0;
|
||||
const lastUpdate = (point.attributes.lastUpdate as number) ?? Date.now();
|
||||
const age = Date.now() - lastUpdate;
|
||||
const decayFactor = Math.exp(-age / config.decayRate);
|
||||
const currentAttention = baseAttention * decayFactor;
|
||||
|
||||
// Filter by minimum attention
|
||||
if (currentAttention < config.minAttention) {
|
||||
return createInvisiblePoint(point.id);
|
||||
}
|
||||
|
||||
// Size based on attention (0.2 - 1.0)
|
||||
const size = 0.2 + currentAttention * 0.8;
|
||||
|
||||
// Opacity based on attention
|
||||
const opacity = 0.3 + currentAttention * 0.7;
|
||||
|
||||
// Color based on attention level
|
||||
const color = getAttentionColor(currentAttention, config.colorGradient);
|
||||
|
||||
return {
|
||||
id: point.id,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
opacity,
|
||||
color,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getAttentionColor(
|
||||
attention: number,
|
||||
gradient: { low: string; medium: string; high: string }
|
||||
): string {
|
||||
if (attention < 0.33) {
|
||||
return interpolateColor(gradient.low, gradient.medium, attention * 3);
|
||||
} else if (attention < 0.67) {
|
||||
return interpolateColor(gradient.medium, gradient.high, (attention - 0.33) * 3);
|
||||
} else {
|
||||
return gradient.high;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Incentive Transform
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Transform a point using the incentive lens
|
||||
* Position based on value gradients
|
||||
*/
|
||||
export function transformIncentive(
|
||||
point: DataPoint,
|
||||
config: IncentiveLensConfig,
|
||||
viewport: LensState['viewport']
|
||||
): TransformedPoint {
|
||||
if (!point.geo && !point.attributes.canvasPosition) {
|
||||
return createInvisiblePoint(point.id);
|
||||
}
|
||||
|
||||
// Base position
|
||||
let x: number, y: number;
|
||||
|
||||
if (point.geo) {
|
||||
x = viewport.centerX + (point.geo.lng / 180) * (viewport.width / 2);
|
||||
y = viewport.centerY - (point.geo.lat / 90) * (viewport.height / 2);
|
||||
} else {
|
||||
const pos = point.attributes.canvasPosition as { x: number; y: number };
|
||||
x = pos.x;
|
||||
y = pos.y;
|
||||
}
|
||||
|
||||
// Normalize value
|
||||
const value = point.value ?? 0;
|
||||
const { min, max } = config.valueRange;
|
||||
const normalizedValue = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
|
||||
// Size based on absolute value
|
||||
const size = 0.3 + normalizedValue * 0.7;
|
||||
|
||||
// Color based on positive/negative
|
||||
const isPositive = value >= 0;
|
||||
const color = isPositive ? config.positiveColor : config.negativeColor;
|
||||
|
||||
// Opacity based on magnitude
|
||||
const opacity = 0.4 + normalizedValue * 0.6;
|
||||
|
||||
return {
|
||||
id: point.id,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
opacity,
|
||||
color,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Relational Transform
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Transform a point using the relational lens
|
||||
* Position based on graph layout algorithm
|
||||
*/
|
||||
export function transformRelational(
|
||||
point: DataPoint,
|
||||
config: RelationalLensConfig,
|
||||
viewport: LensState['viewport'],
|
||||
allPoints?: DataPoint[],
|
||||
layoutCache?: Map<string, { x: number; y: number }>
|
||||
): TransformedPoint {
|
||||
// Use cached layout position if available
|
||||
if (layoutCache?.has(point.id)) {
|
||||
const pos = layoutCache.get(point.id)!;
|
||||
return {
|
||||
id: point.id,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: 0.5,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Without layout cache, use simple circular layout
|
||||
if (!allPoints) {
|
||||
// Fallback: random-ish position based on ID
|
||||
const hash = simpleHash(point.id);
|
||||
const angle = (hash % 360) * (Math.PI / 180);
|
||||
const radius = viewport.width * 0.3;
|
||||
|
||||
return {
|
||||
id: point.id,
|
||||
x: viewport.centerX + Math.cos(angle) * radius,
|
||||
y: viewport.centerY + Math.sin(angle) * radius,
|
||||
size: 0.5,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Circular layout as default
|
||||
const index = allPoints.findIndex((p) => p.id === point.id);
|
||||
const total = allPoints.length;
|
||||
const angle = (index / total) * Math.PI * 2;
|
||||
const radius = Math.min(viewport.width, viewport.height) * 0.35;
|
||||
|
||||
return {
|
||||
id: point.id,
|
||||
x: viewport.centerX + Math.cos(angle) * radius,
|
||||
y: viewport.centerY + Math.sin(angle) * radius,
|
||||
size: 0.5,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run force-directed layout simulation
|
||||
* Returns a map of point IDs to positions
|
||||
*/
|
||||
export function computeForceDirectedLayout(
|
||||
points: DataPoint[],
|
||||
config: RelationalLensConfig,
|
||||
viewport: LensState['viewport'],
|
||||
iterations: number = 100
|
||||
): Map<string, { x: number; y: number }> {
|
||||
// Initialize positions
|
||||
const positions = new Map<string, { x: number; y: number; vx: number; vy: number }>();
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const angle = (i / points.length) * Math.PI * 2;
|
||||
const radius = Math.min(viewport.width, viewport.height) * 0.3;
|
||||
positions.set(points[i].id, {
|
||||
x: viewport.centerX + Math.cos(angle) * radius,
|
||||
y: viewport.centerY + Math.sin(angle) * radius,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Build adjacency
|
||||
const edges: Array<{ source: string; target: string }> = [];
|
||||
for (const point of points) {
|
||||
for (const relatedId of point.relations ?? []) {
|
||||
if (positions.has(relatedId)) {
|
||||
edges.push({ source: point.id, target: relatedId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simulation
|
||||
const { repulsionForce, attractionForce } = config;
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
const cooling = 1 - iter / iterations;
|
||||
|
||||
// Repulsion between all nodes
|
||||
for (const [id1, pos1] of positions) {
|
||||
for (const [id2, pos2] of positions) {
|
||||
if (id1 >= id2) continue;
|
||||
|
||||
const dx = pos2.x - pos1.x;
|
||||
const dy = pos2.y - pos1.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const force = (repulsionForce * cooling) / (dist * dist);
|
||||
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
|
||||
pos1.vx -= fx;
|
||||
pos1.vy -= fy;
|
||||
pos2.vx += fx;
|
||||
pos2.vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
for (const { source, target } of edges) {
|
||||
const pos1 = positions.get(source);
|
||||
const pos2 = positions.get(target);
|
||||
if (!pos1 || !pos2) continue;
|
||||
|
||||
const dx = pos2.x - pos1.x;
|
||||
const dy = pos2.y - pos1.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const force = dist * attractionForce * cooling;
|
||||
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
|
||||
pos1.vx += fx;
|
||||
pos1.vy += fy;
|
||||
pos2.vx -= fx;
|
||||
pos2.vy -= fy;
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const pos of positions.values()) {
|
||||
const dx = viewport.centerX - pos.x;
|
||||
const dy = viewport.centerY - pos.y;
|
||||
pos.vx += dx * 0.01 * cooling;
|
||||
pos.vy += dy * 0.01 * cooling;
|
||||
}
|
||||
|
||||
// Apply velocities
|
||||
for (const pos of positions.values()) {
|
||||
pos.x += pos.vx;
|
||||
pos.y += pos.vy;
|
||||
pos.vx *= 0.8; // Damping
|
||||
pos.vy *= 0.8;
|
||||
|
||||
// Keep in bounds
|
||||
const margin = 50;
|
||||
pos.x = Math.max(margin, Math.min(viewport.width - margin, pos.x));
|
||||
pos.y = Math.max(margin, Math.min(viewport.height - margin, pos.y));
|
||||
}
|
||||
}
|
||||
|
||||
// Return just positions
|
||||
const result = new Map<string, { x: number; y: number }>();
|
||||
for (const [id, pos] of positions) {
|
||||
result.set(id, { x: pos.x, y: pos.y });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Possibility Transform
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Transform a point using the possibility lens
|
||||
* Shows branching timelines and alternate scenarios
|
||||
*/
|
||||
export function transformPossibility(
|
||||
point: DataPoint,
|
||||
config: PossibilityLensConfig,
|
||||
viewport: LensState['viewport']
|
||||
): TransformedPoint {
|
||||
if (!point.timestamp) {
|
||||
return createInvisiblePoint(point.id);
|
||||
}
|
||||
|
||||
const { branchPoint, activeScenario, scenarios, probabilityFade } = config;
|
||||
|
||||
// Get scenario for this point
|
||||
const scenarioId = (point.attributes.scenario as string) ?? 'current';
|
||||
const scenario = scenarios.find((s) => s.id === scenarioId);
|
||||
|
||||
if (!scenario) {
|
||||
return createInvisiblePoint(point.id);
|
||||
}
|
||||
|
||||
// X position based on time (relative to branch point)
|
||||
const timeOffset = point.timestamp - branchPoint;
|
||||
const x = viewport.centerX + timeOffset * 0.001; // Scale factor
|
||||
|
||||
// Y position based on scenario
|
||||
const scenarioIndex = scenarios.findIndex((s) => s.id === scenarioId);
|
||||
const totalScenarios = scenarios.length;
|
||||
const ySpread = viewport.height * 0.6;
|
||||
const y = viewport.centerY + (scenarioIndex - totalScenarios / 2) * (ySpread / totalScenarios);
|
||||
|
||||
// Opacity based on probability and whether active
|
||||
let opacity = scenario.probability ?? 0.5;
|
||||
if (scenarioId !== activeScenario) {
|
||||
opacity *= probabilityFade;
|
||||
}
|
||||
|
||||
// Size based on probability
|
||||
const size = 0.3 + (scenario.probability ?? 0.5) * 0.5;
|
||||
|
||||
return {
|
||||
id: point.id,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
opacity,
|
||||
visible: opacity > 0.1,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
function createInvisiblePoint(id: string): TransformedPoint {
|
||||
return {
|
||||
id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 0,
|
||||
opacity: 0,
|
||||
visible: false,
|
||||
};
|
||||
}
|
||||
|
||||
function simpleHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function interpolateColor(color1: string, color2: string, factor: number): string {
|
||||
const c1 = parseInt(color1.slice(1), 16);
|
||||
const c2 = parseInt(color2.slice(1), 16);
|
||||
|
||||
const r1 = (c1 >> 16) & 255;
|
||||
const g1 = (c1 >> 8) & 255;
|
||||
const b1 = c1 & 255;
|
||||
|
||||
const r2 = (c2 >> 16) & 255;
|
||||
const g2 = (c2 >> 8) & 255;
|
||||
const b2 = c2 & 255;
|
||||
|
||||
const r = Math.round(r1 + (r2 - r1) * factor);
|
||||
const g = Math.round(g1 + (g2 - g1) * factor);
|
||||
const b = Math.round(b1 + (b2 - b1) * factor);
|
||||
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Transform Registry
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get transform function for a lens type
|
||||
*/
|
||||
export function getTransformForLens(lensType: string): LensTransformFn {
|
||||
switch (lensType) {
|
||||
case 'geographic':
|
||||
return transformGeographic as LensTransformFn;
|
||||
case 'temporal':
|
||||
return transformTemporal as LensTransformFn;
|
||||
case 'attention':
|
||||
return transformAttention as LensTransformFn;
|
||||
case 'incentive':
|
||||
return transformIncentive as LensTransformFn;
|
||||
case 'relational':
|
||||
return transformRelational as LensTransformFn;
|
||||
case 'possibility':
|
||||
return transformPossibility as LensTransformFn;
|
||||
default:
|
||||
// Default to geographic
|
||||
return transformGeographic as LensTransformFn;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a point through a lens
|
||||
*/
|
||||
export function transformPoint(
|
||||
point: DataPoint,
|
||||
config: LensConfig,
|
||||
viewport: LensState['viewport']
|
||||
): TransformedPoint {
|
||||
const transform = getTransformForLens(config.type);
|
||||
return transform(point, config, viewport);
|
||||
}
|
||||
|
|
@ -0,0 +1,543 @@
|
|||
/**
|
||||
* Alternative Map Lens System - Type Definitions
|
||||
*
|
||||
* Lenses transform how data is visualized on the canvas. The same underlying
|
||||
* data can be projected through different lenses to reveal different patterns.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Core Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Available lens types
|
||||
*/
|
||||
export type LensType =
|
||||
| 'geographic' // Traditional OSM basemap, physical locations
|
||||
| 'temporal' // Time as X-axis, events as nodes
|
||||
| 'attention' // Heatmap of collective focus
|
||||
| 'incentive' // Value gradients, token flows
|
||||
| 'relational' // Social graph topology
|
||||
| 'possibility' // Branching futures, what-if scenarios
|
||||
| 'custom'; // User-defined lens
|
||||
|
||||
/**
|
||||
* A point in the source data space
|
||||
*/
|
||||
export interface DataPoint {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Geographic coordinates (if applicable) */
|
||||
geo?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
|
||||
/** Timestamp (if applicable) */
|
||||
timestamp?: number;
|
||||
|
||||
/** Attention/focus value (0-1) */
|
||||
attention?: number;
|
||||
|
||||
/** Value/incentive score */
|
||||
value?: number;
|
||||
|
||||
/** Related entities (for relational lens) */
|
||||
relations?: string[];
|
||||
|
||||
/** Custom attributes */
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A point after lens transformation
|
||||
*/
|
||||
export interface TransformedPoint {
|
||||
/** Original data point ID */
|
||||
id: string;
|
||||
|
||||
/** Canvas X coordinate */
|
||||
x: number;
|
||||
|
||||
/** Canvas Y coordinate */
|
||||
y: number;
|
||||
|
||||
/** Optional Z for 3D effects */
|
||||
z?: number;
|
||||
|
||||
/** Visual size (0-1 scale) */
|
||||
size: number;
|
||||
|
||||
/** Visual opacity (0-1) */
|
||||
opacity: number;
|
||||
|
||||
/** Color (hex) */
|
||||
color?: string;
|
||||
|
||||
/** Whether this point is visible in current lens */
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Lens Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Base lens configuration
|
||||
*/
|
||||
export interface BaseLensConfig {
|
||||
/** Lens type */
|
||||
type: LensType;
|
||||
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
|
||||
/** Icon for UI */
|
||||
icon?: string;
|
||||
|
||||
/** Whether lens is active */
|
||||
active: boolean;
|
||||
|
||||
/** Blend weight when multiple lenses active (0-1) */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geographic lens configuration
|
||||
*/
|
||||
export interface GeographicLensConfig extends BaseLensConfig {
|
||||
type: 'geographic';
|
||||
|
||||
/** Map style URL */
|
||||
styleUrl?: string;
|
||||
|
||||
/** Center point */
|
||||
center: { lat: number; lng: number };
|
||||
|
||||
/** Zoom level */
|
||||
zoom: number;
|
||||
|
||||
/** Bearing (rotation) */
|
||||
bearing: number;
|
||||
|
||||
/** Pitch (tilt) */
|
||||
pitch: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporal lens configuration
|
||||
*/
|
||||
export interface TemporalLensConfig extends BaseLensConfig {
|
||||
type: 'temporal';
|
||||
|
||||
/** Time range to display */
|
||||
timeRange: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
/** Current scrubber position */
|
||||
currentTime: number;
|
||||
|
||||
/** Time scale (pixels per millisecond) */
|
||||
timeScale: number;
|
||||
|
||||
/** Whether to animate playback */
|
||||
playing: boolean;
|
||||
|
||||
/** Playback speed multiplier */
|
||||
playbackSpeed: number;
|
||||
|
||||
/** Vertical grouping strategy */
|
||||
groupBy: 'type' | 'owner' | 'location' | 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Attention lens configuration
|
||||
*/
|
||||
export interface AttentionLensConfig extends BaseLensConfig {
|
||||
type: 'attention';
|
||||
|
||||
/** Decay rate for attention (half-life in ms) */
|
||||
decayRate: number;
|
||||
|
||||
/** Minimum attention to display */
|
||||
minAttention: number;
|
||||
|
||||
/** Color gradient */
|
||||
colorGradient: {
|
||||
low: string;
|
||||
medium: string;
|
||||
high: string;
|
||||
};
|
||||
|
||||
/** Whether to show heatmap overlay */
|
||||
showHeatmap: boolean;
|
||||
|
||||
/** Heatmap radius (pixels) */
|
||||
heatmapRadius: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incentive lens configuration
|
||||
*/
|
||||
export interface IncentiveLensConfig extends BaseLensConfig {
|
||||
type: 'incentive';
|
||||
|
||||
/** Value range to normalize */
|
||||
valueRange: { min: number; max: number };
|
||||
|
||||
/** Color for positive values */
|
||||
positiveColor: string;
|
||||
|
||||
/** Color for negative values */
|
||||
negativeColor: string;
|
||||
|
||||
/** Whether to show flow arrows */
|
||||
showFlows: boolean;
|
||||
|
||||
/** Token type to visualize */
|
||||
tokenType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relational lens configuration
|
||||
*/
|
||||
export interface RelationalLensConfig extends BaseLensConfig {
|
||||
type: 'relational';
|
||||
|
||||
/** Layout algorithm */
|
||||
layout: 'force-directed' | 'radial' | 'hierarchical' | 'circular';
|
||||
|
||||
/** Center node (if any) */
|
||||
focusNodeId?: string;
|
||||
|
||||
/** Maximum depth from focus */
|
||||
maxDepth: number;
|
||||
|
||||
/** Edge visibility threshold */
|
||||
minEdgeStrength: number;
|
||||
|
||||
/** Node repulsion force */
|
||||
repulsionForce: number;
|
||||
|
||||
/** Edge attraction force */
|
||||
attractionForce: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Possibility lens configuration
|
||||
*/
|
||||
export interface PossibilityLensConfig extends BaseLensConfig {
|
||||
type: 'possibility';
|
||||
|
||||
/** Branch point in time */
|
||||
branchPoint: number;
|
||||
|
||||
/** Active scenario/branch */
|
||||
activeScenario: string;
|
||||
|
||||
/** All available scenarios */
|
||||
scenarios: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
probability?: number;
|
||||
}>;
|
||||
|
||||
/** Whether to show probability distributions */
|
||||
showProbabilities: boolean;
|
||||
|
||||
/** Fade factor for unlikely scenarios */
|
||||
probabilityFade: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom lens configuration
|
||||
*/
|
||||
export interface CustomLensConfig extends BaseLensConfig {
|
||||
type: 'custom';
|
||||
|
||||
/** Custom transform function name */
|
||||
transformFn: string;
|
||||
|
||||
/** Custom parameters */
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all lens configs
|
||||
*/
|
||||
export type LensConfig =
|
||||
| GeographicLensConfig
|
||||
| TemporalLensConfig
|
||||
| AttentionLensConfig
|
||||
| IncentiveLensConfig
|
||||
| RelationalLensConfig
|
||||
| PossibilityLensConfig
|
||||
| CustomLensConfig;
|
||||
|
||||
// =============================================================================
|
||||
// Lens State
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Current state of the lens system
|
||||
*/
|
||||
export interface LensState {
|
||||
/** Active lenses (can blend multiple) */
|
||||
activeLenses: LensConfig[];
|
||||
|
||||
/** Transition in progress */
|
||||
transition?: LensTransition;
|
||||
|
||||
/** Canvas viewport */
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
/** Cached transformed points */
|
||||
transformedPoints: Map<string, TransformedPoint>;
|
||||
|
||||
/** Last update timestamp */
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lens transition (blending between states)
|
||||
*/
|
||||
export interface LensTransition {
|
||||
/** Transition ID */
|
||||
id: string;
|
||||
|
||||
/** Starting lens configuration */
|
||||
from: LensConfig[];
|
||||
|
||||
/** Target lens configuration */
|
||||
to: LensConfig[];
|
||||
|
||||
/** Transition duration (ms) */
|
||||
duration: number;
|
||||
|
||||
/** Start timestamp */
|
||||
startTime: number;
|
||||
|
||||
/** Easing function */
|
||||
easing: EasingFunction;
|
||||
|
||||
/** Current progress (0-1) */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Easing function types
|
||||
*/
|
||||
export type EasingFunction =
|
||||
| 'linear'
|
||||
| 'ease-in'
|
||||
| 'ease-out'
|
||||
| 'ease-in-out'
|
||||
| 'spring'
|
||||
| 'bounce';
|
||||
|
||||
// =============================================================================
|
||||
// Transform Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Transform function signature
|
||||
*/
|
||||
export type LensTransformFn = (
|
||||
point: DataPoint,
|
||||
config: LensConfig,
|
||||
viewport: LensState['viewport']
|
||||
) => TransformedPoint;
|
||||
|
||||
/**
|
||||
* Blending function signature
|
||||
*/
|
||||
export type BlendFn = (
|
||||
points: TransformedPoint[],
|
||||
weights: number[]
|
||||
) => TransformedPoint;
|
||||
|
||||
/**
|
||||
* Registry of custom transforms
|
||||
*/
|
||||
export interface TransformRegistry {
|
||||
transforms: Map<LensType | string, LensTransformFn>;
|
||||
blenders: Map<string, BlendFn>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Events
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Lens system events
|
||||
*/
|
||||
export type LensEvent =
|
||||
| { type: 'lens:activated'; lens: LensConfig }
|
||||
| { type: 'lens:deactivated'; lensType: LensType }
|
||||
| { type: 'lens:updated'; lens: LensConfig }
|
||||
| { type: 'transition:started'; transition: LensTransition }
|
||||
| { type: 'transition:progress'; transition: LensTransition }
|
||||
| { type: 'transition:completed'; transition: LensTransition }
|
||||
| { type: 'points:transformed'; count: number }
|
||||
| { type: 'temporal:scrub'; time: number }
|
||||
| { type: 'temporal:play' }
|
||||
| { type: 'temporal:pause' };
|
||||
|
||||
/**
|
||||
* Lens event listener
|
||||
*/
|
||||
export type LensEventListener = (event: LensEvent) => void;
|
||||
|
||||
// =============================================================================
|
||||
// Temporal Portal
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A temporal portal (view into another time at a location)
|
||||
*/
|
||||
export interface TemporalPortal {
|
||||
/** Portal ID */
|
||||
id: string;
|
||||
|
||||
/** Geographic location */
|
||||
location: { lat: number; lng: number };
|
||||
|
||||
/** Canvas position */
|
||||
position: { x: number; y: number };
|
||||
|
||||
/** Target time */
|
||||
targetTime: number;
|
||||
|
||||
/** Portal radius (pixels) */
|
||||
radius: number;
|
||||
|
||||
/** Whether portal is active */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Default Configurations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Default geographic lens
|
||||
*/
|
||||
export const DEFAULT_GEOGRAPHIC_LENS: GeographicLensConfig = {
|
||||
type: 'geographic',
|
||||
name: 'Geographic',
|
||||
icon: '🗺️',
|
||||
active: true,
|
||||
weight: 1,
|
||||
center: { lat: 0, lng: 0 },
|
||||
zoom: 2,
|
||||
bearing: 0,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default temporal lens
|
||||
*/
|
||||
export const DEFAULT_TEMPORAL_LENS: TemporalLensConfig = {
|
||||
type: 'temporal',
|
||||
name: 'Timeline',
|
||||
icon: '⏱️',
|
||||
active: false,
|
||||
weight: 1,
|
||||
timeRange: {
|
||||
start: Date.now() - 7 * 24 * 60 * 60 * 1000, // 1 week ago
|
||||
end: Date.now(),
|
||||
},
|
||||
currentTime: Date.now(),
|
||||
timeScale: 0.0001, // 1 pixel per 10 seconds
|
||||
playing: false,
|
||||
playbackSpeed: 1,
|
||||
groupBy: 'type',
|
||||
};
|
||||
|
||||
/**
|
||||
* Default attention lens
|
||||
*/
|
||||
export const DEFAULT_ATTENTION_LENS: AttentionLensConfig = {
|
||||
type: 'attention',
|
||||
name: 'Attention',
|
||||
icon: '👁️',
|
||||
active: false,
|
||||
weight: 1,
|
||||
decayRate: 60000, // 1 minute half-life
|
||||
minAttention: 0.1,
|
||||
colorGradient: {
|
||||
low: '#3b82f6', // Blue
|
||||
medium: '#eab308', // Yellow
|
||||
high: '#ef4444', // Red
|
||||
},
|
||||
showHeatmap: true,
|
||||
heatmapRadius: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default incentive lens
|
||||
*/
|
||||
export const DEFAULT_INCENTIVE_LENS: IncentiveLensConfig = {
|
||||
type: 'incentive',
|
||||
name: 'Value',
|
||||
icon: '💰',
|
||||
active: false,
|
||||
weight: 1,
|
||||
valueRange: { min: 0, max: 1000 },
|
||||
positiveColor: '#22c55e',
|
||||
negativeColor: '#ef4444',
|
||||
showFlows: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default relational lens
|
||||
*/
|
||||
export const DEFAULT_RELATIONAL_LENS: RelationalLensConfig = {
|
||||
type: 'relational',
|
||||
name: 'Network',
|
||||
icon: '🕸️',
|
||||
active: false,
|
||||
weight: 1,
|
||||
layout: 'force-directed',
|
||||
maxDepth: 3,
|
||||
minEdgeStrength: 0.1,
|
||||
repulsionForce: 100,
|
||||
attractionForce: 0.1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default possibility lens
|
||||
*/
|
||||
export const DEFAULT_POSSIBILITY_LENS: PossibilityLensConfig = {
|
||||
type: 'possibility',
|
||||
name: 'Possibilities',
|
||||
icon: '🌳',
|
||||
active: false,
|
||||
weight: 1,
|
||||
branchPoint: Date.now(),
|
||||
activeScenario: 'current',
|
||||
scenarios: [{ id: 'current', name: 'Current Path', probability: 1 }],
|
||||
showProbabilities: true,
|
||||
probabilityFade: 0.5,
|
||||
};
|
||||
|
||||
/**
|
||||
* All default lenses
|
||||
*/
|
||||
export const DEFAULT_LENSES: LensConfig[] = [
|
||||
DEFAULT_GEOGRAPHIC_LENS,
|
||||
DEFAULT_TEMPORAL_LENS,
|
||||
DEFAULT_ATTENTION_LENS,
|
||||
DEFAULT_INCENTIVE_LENS,
|
||||
DEFAULT_RELATIONAL_LENS,
|
||||
DEFAULT_POSSIBILITY_LENS,
|
||||
];
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Mycelium Network Module
|
||||
*
|
||||
* A biologically-inspired signal propagation system for collaborative spaces.
|
||||
* Models how information, attention, and value flow through a network
|
||||
* like nutrients through mycelium.
|
||||
*
|
||||
* Features:
|
||||
* - Nodes: Points of interest, events, people, resources
|
||||
* - Hyphae: Connections between nodes
|
||||
* - Signals: Information that propagates through the network
|
||||
* - Resonance: Detection of convergent attention patterns
|
||||
*/
|
||||
|
||||
// Core types
|
||||
export * from './types';
|
||||
|
||||
// Signal propagation
|
||||
export {
|
||||
applyDecay,
|
||||
calculateMultiDecay,
|
||||
createSignal,
|
||||
isSignalAlive,
|
||||
propagateFlood,
|
||||
propagateGradient,
|
||||
propagateRandomWalk,
|
||||
propagateDiffusion,
|
||||
propagateSignal,
|
||||
aggregateSignals,
|
||||
DEFAULT_DECAY_CONFIG,
|
||||
DEFAULT_PROPAGATION_CONFIG,
|
||||
type PropagationStep,
|
||||
} from './signals';
|
||||
|
||||
// Network management
|
||||
export {
|
||||
MyceliumNetwork,
|
||||
createMyceliumNetwork,
|
||||
DEFAULT_NETWORK_CONFIG,
|
||||
type NetworkConfig,
|
||||
} from './network';
|
||||
|
||||
// Visualization
|
||||
export {
|
||||
NODE_COLORS,
|
||||
SIGNAL_COLORS,
|
||||
HYPHA_COLORS,
|
||||
getNodeVisualization,
|
||||
getNodeStyle,
|
||||
getHyphaVisualization,
|
||||
getHyphaPathAttrs,
|
||||
getSignalVisualization,
|
||||
getSignalParticleStyle,
|
||||
getResonanceVisualization,
|
||||
interpolateColor,
|
||||
getHeatMapColor,
|
||||
getStrengthColor,
|
||||
drawNode,
|
||||
drawHypha,
|
||||
drawResonance,
|
||||
getSignalPosition,
|
||||
PULSE_KEYFRAMES,
|
||||
FLOW_KEYFRAMES,
|
||||
RIPPLE_KEYFRAMES,
|
||||
} from './visualization';
|
||||
|
|
@ -0,0 +1,963 @@
|
|||
/**
|
||||
* Mycelial Network Manager
|
||||
*
|
||||
* Central coordinator for the mycelium network. Manages nodes, hyphae,
|
||||
* signal propagation, and resonance detection.
|
||||
*/
|
||||
|
||||
import type {
|
||||
MyceliumNode,
|
||||
NodeType,
|
||||
Hypha,
|
||||
HyphaType,
|
||||
Signal,
|
||||
SignalEmissionConfig,
|
||||
PropagationConfig,
|
||||
ResonanceConfig,
|
||||
Resonance,
|
||||
MyceliumNetworkState,
|
||||
NetworkStats,
|
||||
MyceliumEvent,
|
||||
MyceliumEventListener,
|
||||
} from './types';
|
||||
import {
|
||||
createSignal,
|
||||
propagateSignal,
|
||||
aggregateSignals,
|
||||
isSignalAlive,
|
||||
DEFAULT_PROPAGATION_CONFIG,
|
||||
PropagationStep,
|
||||
} from './signals';
|
||||
|
||||
// =============================================================================
|
||||
// Network Manager
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for the network manager
|
||||
*/
|
||||
export interface NetworkConfig {
|
||||
/** Propagation settings */
|
||||
propagation: PropagationConfig;
|
||||
|
||||
/** Resonance detection settings */
|
||||
resonance: ResonanceConfig;
|
||||
|
||||
/** How often to run maintenance (ms) */
|
||||
maintenanceInterval: number;
|
||||
|
||||
/** How often to update stats (ms) */
|
||||
statsInterval: number;
|
||||
|
||||
/** Maximum active signals */
|
||||
maxActiveSignals: number;
|
||||
|
||||
/** Node expiration time (ms, 0 = never) */
|
||||
nodeExpiration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default network configuration
|
||||
*/
|
||||
export const DEFAULT_NETWORK_CONFIG: NetworkConfig = {
|
||||
propagation: DEFAULT_PROPAGATION_CONFIG,
|
||||
resonance: {
|
||||
minParticipants: 2,
|
||||
maxDistance: 1000, // 1km
|
||||
timeWindow: 300000, // 5 minutes
|
||||
minStrength: 0.3,
|
||||
serendipitousOnly: false,
|
||||
},
|
||||
maintenanceInterval: 10000, // 10 seconds
|
||||
statsInterval: 5000, // 5 seconds
|
||||
maxActiveSignals: 1000,
|
||||
nodeExpiration: 0, // Never expire by default
|
||||
};
|
||||
|
||||
/**
|
||||
* The Mycelial Network Manager
|
||||
*/
|
||||
export class MyceliumNetwork {
|
||||
private nodes: Map<string, MyceliumNode> = new Map();
|
||||
private hyphae: Map<string, Hypha> = new Map();
|
||||
private activeSignals: Map<string, Signal> = new Map();
|
||||
private resonances: Map<string, Resonance> = new Map();
|
||||
private signalQueue: Signal[] = [];
|
||||
private nodeSignals: Map<string, Signal[]> = new Map(); // Signals at each node
|
||||
private listeners: Set<MyceliumEventListener> = new Set();
|
||||
private config: NetworkConfig;
|
||||
private maintenanceTimer?: ReturnType<typeof setInterval>;
|
||||
private statsTimer?: ReturnType<typeof setInterval>;
|
||||
private stats: NetworkStats = {
|
||||
nodeCount: 0,
|
||||
hyphaCount: 0,
|
||||
activeSignalCount: 0,
|
||||
resonanceCount: 0,
|
||||
avgNodeStrength: 0,
|
||||
density: 0,
|
||||
};
|
||||
|
||||
constructor(config: Partial<NetworkConfig> = {}) {
|
||||
this.config = { ...DEFAULT_NETWORK_CONFIG, ...config };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lifecycle
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Start the network (background processing)
|
||||
*/
|
||||
start(): void {
|
||||
// Maintenance loop: clean up expired signals, nodes
|
||||
this.maintenanceTimer = setInterval(
|
||||
() => this.runMaintenance(),
|
||||
this.config.maintenanceInterval
|
||||
);
|
||||
|
||||
// Stats loop: update network statistics
|
||||
this.statsTimer = setInterval(
|
||||
() => this.updateStats(),
|
||||
this.config.statsInterval
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the network
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.maintenanceTimer) {
|
||||
clearInterval(this.maintenanceTimer);
|
||||
this.maintenanceTimer = undefined;
|
||||
}
|
||||
if (this.statsTimer) {
|
||||
clearInterval(this.statsTimer);
|
||||
this.statsTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current network state
|
||||
*/
|
||||
getState(): MyceliumNetworkState {
|
||||
return {
|
||||
nodes: new Map(this.nodes),
|
||||
hyphae: new Map(this.hyphae),
|
||||
activeSignals: new Map(this.activeSignals),
|
||||
resonances: new Map(this.resonances),
|
||||
stats: { ...this.stats },
|
||||
lastUpdate: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Node Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create a new node
|
||||
*/
|
||||
createNode(params: {
|
||||
type: NodeType;
|
||||
label: string;
|
||||
position?: { lat: number; lng: number };
|
||||
canvasPosition?: { x: number; y: number };
|
||||
metadata?: Record<string, unknown>;
|
||||
ownerId?: string;
|
||||
tags?: string[];
|
||||
}): MyceliumNode {
|
||||
const now = Date.now();
|
||||
const node: MyceliumNode = {
|
||||
id: this.generateId('node'),
|
||||
type: params.type,
|
||||
label: params.label,
|
||||
position: params.position,
|
||||
canvasPosition: params.canvasPosition,
|
||||
createdAt: now,
|
||||
lastActiveAt: now,
|
||||
signalStrength: 0,
|
||||
receivedSignal: 0,
|
||||
metadata: params.metadata ?? {},
|
||||
hyphae: [],
|
||||
ownerId: params.ownerId,
|
||||
tags: params.tags ?? [],
|
||||
};
|
||||
|
||||
this.nodes.set(node.id, node);
|
||||
this.nodeSignals.set(node.id, []);
|
||||
this.emit({ type: 'node:created', node });
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a node
|
||||
*/
|
||||
updateNode(nodeId: string, updates: Partial<MyceliumNode>): MyceliumNode | null {
|
||||
const node = this.nodes.get(nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
const updated = { ...node, ...updates, id: nodeId, lastActiveAt: Date.now() };
|
||||
this.nodes.set(nodeId, updated);
|
||||
this.emit({ type: 'node:updated', node: updated });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node
|
||||
*/
|
||||
removeNode(nodeId: string): boolean {
|
||||
const node = this.nodes.get(nodeId);
|
||||
if (!node) return false;
|
||||
|
||||
// Remove all connected hyphae
|
||||
for (const hyphaId of [...node.hyphae]) {
|
||||
this.removeHypha(hyphaId);
|
||||
}
|
||||
|
||||
this.nodes.delete(nodeId);
|
||||
this.nodeSignals.delete(nodeId);
|
||||
this.emit({ type: 'node:removed', nodeId });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node by ID
|
||||
*/
|
||||
getNode(nodeId: string): MyceliumNode | undefined {
|
||||
return this.nodes.get(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all nodes
|
||||
*/
|
||||
getAllNodes(): MyceliumNode[] {
|
||||
return Array.from(this.nodes.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Find nodes by criteria
|
||||
*/
|
||||
findNodes(criteria: {
|
||||
type?: NodeType;
|
||||
ownerId?: string;
|
||||
tags?: string[];
|
||||
withinRadius?: { lat: number; lng: number; meters: number };
|
||||
}): MyceliumNode[] {
|
||||
return Array.from(this.nodes.values()).filter((node) => {
|
||||
if (criteria.type && node.type !== criteria.type) return false;
|
||||
if (criteria.ownerId && node.ownerId !== criteria.ownerId) return false;
|
||||
if (criteria.tags && !criteria.tags.every((t) => node.tags.includes(t))) {
|
||||
return false;
|
||||
}
|
||||
if (criteria.withinRadius && node.position) {
|
||||
const dist = this.haversineDistance(
|
||||
node.position.lat,
|
||||
node.position.lng,
|
||||
criteria.withinRadius.lat,
|
||||
criteria.withinRadius.lng
|
||||
);
|
||||
if (dist > criteria.withinRadius.meters) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Hypha Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create a connection between nodes
|
||||
*/
|
||||
createHypha(params: {
|
||||
type: HyphaType;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
strength?: number;
|
||||
directed?: boolean;
|
||||
conductance?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Hypha | null {
|
||||
const source = this.nodes.get(params.sourceId);
|
||||
const target = this.nodes.get(params.targetId);
|
||||
|
||||
if (!source || !target) return null;
|
||||
|
||||
const hypha: Hypha = {
|
||||
id: this.generateId('hypha'),
|
||||
type: params.type,
|
||||
sourceId: params.sourceId,
|
||||
targetId: params.targetId,
|
||||
strength: params.strength ?? 1,
|
||||
directed: params.directed ?? false,
|
||||
conductance: params.conductance ?? 1,
|
||||
createdAt: Date.now(),
|
||||
metadata: params.metadata ?? {},
|
||||
};
|
||||
|
||||
this.hyphae.set(hypha.id, hypha);
|
||||
|
||||
// Update node connections
|
||||
source.hyphae.push(hypha.id);
|
||||
target.hyphae.push(hypha.id);
|
||||
|
||||
this.emit({ type: 'hypha:created', hypha });
|
||||
|
||||
return hypha;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a hypha
|
||||
*/
|
||||
updateHypha(hyphaId: string, updates: Partial<Hypha>): Hypha | null {
|
||||
const hypha = this.hyphae.get(hyphaId);
|
||||
if (!hypha) return null;
|
||||
|
||||
const updated = { ...hypha, ...updates, id: hyphaId };
|
||||
this.hyphae.set(hyphaId, updated);
|
||||
this.emit({ type: 'hypha:updated', hypha: updated });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a hypha
|
||||
*/
|
||||
removeHypha(hyphaId: string): boolean {
|
||||
const hypha = this.hyphae.get(hyphaId);
|
||||
if (!hypha) return false;
|
||||
|
||||
// Remove from connected nodes
|
||||
const source = this.nodes.get(hypha.sourceId);
|
||||
const target = this.nodes.get(hypha.targetId);
|
||||
|
||||
if (source) {
|
||||
source.hyphae = source.hyphae.filter((id) => id !== hyphaId);
|
||||
}
|
||||
if (target) {
|
||||
target.hyphae = target.hyphae.filter((id) => id !== hyphaId);
|
||||
}
|
||||
|
||||
this.hyphae.delete(hyphaId);
|
||||
this.emit({ type: 'hypha:removed', hyphaId });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hyphae connected to a node
|
||||
*/
|
||||
getNodeHyphae(nodeId: string): Hypha[] {
|
||||
const node = this.nodes.get(nodeId);
|
||||
if (!node) return [];
|
||||
|
||||
return node.hyphae
|
||||
.map((id) => this.hyphae.get(id))
|
||||
.filter((h): h is Hypha => h !== undefined);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Signal Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Emit a signal from a node
|
||||
*/
|
||||
emitSignal(
|
||||
sourceNodeId: string,
|
||||
emitterId: string,
|
||||
config: SignalEmissionConfig
|
||||
): Signal | null {
|
||||
const sourceNode = this.nodes.get(sourceNodeId);
|
||||
if (!sourceNode) return null;
|
||||
|
||||
// Check signal limit
|
||||
if (this.activeSignals.size >= this.config.maxActiveSignals) {
|
||||
// Remove oldest signal
|
||||
const oldest = Array.from(this.activeSignals.values()).sort(
|
||||
(a, b) => a.emittedAt - b.emittedAt
|
||||
)[0];
|
||||
if (oldest) {
|
||||
this.removeSignal(oldest.id);
|
||||
}
|
||||
}
|
||||
|
||||
const signal = createSignal(sourceNodeId, emitterId, config);
|
||||
|
||||
this.activeSignals.set(signal.id, signal);
|
||||
|
||||
// Add to source node's signals
|
||||
const nodeSignals = this.nodeSignals.get(sourceNodeId) ?? [];
|
||||
nodeSignals.push(signal);
|
||||
this.nodeSignals.set(sourceNodeId, nodeSignals);
|
||||
|
||||
// Update source node
|
||||
sourceNode.signalStrength = Math.min(1, sourceNode.signalStrength + signal.currentStrength);
|
||||
sourceNode.lastActiveAt = Date.now();
|
||||
|
||||
this.emit({ type: 'signal:emitted', signal });
|
||||
|
||||
// Queue for propagation
|
||||
this.signalQueue.push(signal);
|
||||
|
||||
// Process queue
|
||||
this.processSignalQueue();
|
||||
|
||||
return signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process queued signals
|
||||
*/
|
||||
private processSignalQueue(): void {
|
||||
while (this.signalQueue.length > 0) {
|
||||
const signal = this.signalQueue.shift()!;
|
||||
|
||||
if (!isSignalAlive(signal, this.config.propagation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentNodeId = signal.path[signal.path.length - 1];
|
||||
const visited = new Set(signal.path);
|
||||
|
||||
const steps = propagateSignal(
|
||||
signal,
|
||||
currentNodeId,
|
||||
this.nodes,
|
||||
this.hyphae,
|
||||
this.config.propagation,
|
||||
visited
|
||||
);
|
||||
|
||||
for (const step of steps) {
|
||||
this.applyPropagationStep(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a propagation step
|
||||
*/
|
||||
private applyPropagationStep(step: PropagationStep): void {
|
||||
const { targetNodeId, signal, viaHyphaId } = step;
|
||||
|
||||
const targetNode = this.nodes.get(targetNodeId);
|
||||
if (!targetNode) return;
|
||||
|
||||
// Add signal to node
|
||||
const nodeSignals = this.nodeSignals.get(targetNodeId) ?? [];
|
||||
nodeSignals.push(signal);
|
||||
this.nodeSignals.set(targetNodeId, nodeSignals);
|
||||
|
||||
// Aggregate signals and update node
|
||||
if (this.config.propagation.aggregate) {
|
||||
const aggregated = aggregateSignals(
|
||||
nodeSignals,
|
||||
this.config.propagation.aggregateFn
|
||||
);
|
||||
targetNode.receivedSignal = aggregated;
|
||||
} else {
|
||||
targetNode.receivedSignal = signal.currentStrength;
|
||||
}
|
||||
|
||||
targetNode.lastActiveAt = Date.now();
|
||||
|
||||
// Update hypha (mark signal flow)
|
||||
const hypha = this.hyphae.get(viaHyphaId);
|
||||
if (hypha) {
|
||||
hypha.lastSignalAt = Date.now();
|
||||
}
|
||||
|
||||
this.emit({ type: 'signal:propagated', signal, toNodeId: targetNodeId });
|
||||
|
||||
// Continue propagation if alive
|
||||
if (isSignalAlive(signal, this.config.propagation)) {
|
||||
this.signalQueue.push(signal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a signal
|
||||
*/
|
||||
removeSignal(signalId: string): boolean {
|
||||
const signal = this.activeSignals.get(signalId);
|
||||
if (!signal) return false;
|
||||
|
||||
this.activeSignals.delete(signalId);
|
||||
|
||||
// Remove from all nodes
|
||||
for (const [nodeId, signals] of this.nodeSignals) {
|
||||
const filtered = signals.filter((s) => s.id !== signalId);
|
||||
if (filtered.length !== signals.length) {
|
||||
this.nodeSignals.set(nodeId, filtered);
|
||||
|
||||
// Update node strength
|
||||
const node = this.nodes.get(nodeId);
|
||||
if (node) {
|
||||
node.receivedSignal = aggregateSignals(
|
||||
filtered,
|
||||
this.config.propagation.aggregateFn
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({ type: 'signal:expired', signalId });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Resonance Detection
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Detect resonance patterns in the network
|
||||
*/
|
||||
detectResonance(): Resonance[] {
|
||||
const config = this.config.resonance;
|
||||
const now = Date.now();
|
||||
const newResonances: Resonance[] = [];
|
||||
|
||||
// Get recently active nodes
|
||||
const activeNodes = Array.from(this.nodes.values()).filter(
|
||||
(n) => n.position && now - n.lastActiveAt < config.timeWindow
|
||||
);
|
||||
|
||||
// Group by geographic proximity
|
||||
const clusters = this.clusterByProximity(activeNodes, config.maxDistance);
|
||||
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.length < config.minParticipants) continue;
|
||||
|
||||
// Get unique owners
|
||||
const participants = [...new Set(cluster.map((n) => n.ownerId).filter(Boolean) as string[])];
|
||||
if (participants.length < config.minParticipants) continue;
|
||||
|
||||
// Check if serendipitous (unconnected)
|
||||
const isSerendipitous = !this.areNodesConnected(cluster.map((n) => n.id));
|
||||
|
||||
if (config.serendipitousOnly && !isSerendipitous) continue;
|
||||
|
||||
// Calculate center and strength
|
||||
const center = this.calculateCentroid(cluster);
|
||||
const strength = this.calculateResonanceStrength(cluster);
|
||||
|
||||
if (strength < config.minStrength) continue;
|
||||
|
||||
// Check for existing resonance in this area
|
||||
const existingId = this.findExistingResonance(center, config.maxDistance);
|
||||
|
||||
if (existingId) {
|
||||
// Update existing
|
||||
const existing = this.resonances.get(existingId)!;
|
||||
existing.participants = participants;
|
||||
existing.strength = strength;
|
||||
existing.updatedAt = now;
|
||||
existing.isSerendipitous = isSerendipitous;
|
||||
|
||||
this.emit({ type: 'resonance:updated', resonance: existing });
|
||||
} else {
|
||||
// Create new
|
||||
const resonance: Resonance = {
|
||||
id: this.generateId('resonance'),
|
||||
center,
|
||||
radius: config.maxDistance,
|
||||
participants,
|
||||
strength,
|
||||
detectedAt: now,
|
||||
updatedAt: now,
|
||||
isSerendipitous,
|
||||
};
|
||||
|
||||
this.resonances.set(resonance.id, resonance);
|
||||
newResonances.push(resonance);
|
||||
|
||||
this.emit({ type: 'resonance:detected', resonance });
|
||||
}
|
||||
}
|
||||
|
||||
return newResonances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if nodes are connected
|
||||
*/
|
||||
private areNodesConnected(nodeIds: string[]): boolean {
|
||||
if (nodeIds.length < 2) return true;
|
||||
|
||||
// BFS from first node
|
||||
const visited = new Set<string>();
|
||||
const queue = [nodeIds[0]];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
|
||||
const node = this.nodes.get(current);
|
||||
if (!node) continue;
|
||||
|
||||
for (const hyphaId of node.hyphae) {
|
||||
const hypha = this.hyphae.get(hyphaId);
|
||||
if (!hypha) continue;
|
||||
|
||||
const other = hypha.sourceId === current ? hypha.targetId : hypha.sourceId;
|
||||
if (nodeIds.includes(other) && !visited.has(other)) {
|
||||
queue.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all nodes were reached
|
||||
return nodeIds.every((id) => visited.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster nodes by proximity
|
||||
*/
|
||||
private clusterByProximity(
|
||||
nodes: MyceliumNode[],
|
||||
maxDistance: number
|
||||
): MyceliumNode[][] {
|
||||
const clusters: MyceliumNode[][] = [];
|
||||
const assigned = new Set<string>();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (assigned.has(node.id)) continue;
|
||||
if (!node.position) continue;
|
||||
|
||||
const cluster = [node];
|
||||
assigned.add(node.id);
|
||||
|
||||
// Find nearby nodes
|
||||
for (const other of nodes) {
|
||||
if (assigned.has(other.id)) continue;
|
||||
if (!other.position) continue;
|
||||
|
||||
const dist = this.haversineDistance(
|
||||
node.position.lat,
|
||||
node.position.lng,
|
||||
other.position.lat,
|
||||
other.position.lng
|
||||
);
|
||||
|
||||
if (dist <= maxDistance) {
|
||||
cluster.push(other);
|
||||
assigned.add(other.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (cluster.length > 0) {
|
||||
clusters.push(cluster);
|
||||
}
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate centroid of nodes
|
||||
*/
|
||||
private calculateCentroid(nodes: MyceliumNode[]): { lat: number; lng: number } {
|
||||
const positions = nodes
|
||||
.map((n) => n.position)
|
||||
.filter((p): p is { lat: number; lng: number } => p !== undefined);
|
||||
|
||||
if (positions.length === 0) {
|
||||
return { lat: 0, lng: 0 };
|
||||
}
|
||||
|
||||
const sumLat = positions.reduce((s, p) => s + p.lat, 0);
|
||||
const sumLng = positions.reduce((s, p) => s + p.lng, 0);
|
||||
|
||||
return {
|
||||
lat: sumLat / positions.length,
|
||||
lng: sumLng / positions.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate resonance strength
|
||||
*/
|
||||
private calculateResonanceStrength(nodes: MyceliumNode[]): number {
|
||||
if (nodes.length === 0) return 0;
|
||||
|
||||
// Average signal strength + bonus for more participants
|
||||
const avgStrength =
|
||||
nodes.reduce((s, n) => s + n.signalStrength + n.receivedSignal, 0) /
|
||||
nodes.length;
|
||||
const participantBonus = Math.min(1, nodes.length / 10);
|
||||
|
||||
return Math.min(1, avgStrength + participantBonus * 0.3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing resonance near a point
|
||||
*/
|
||||
private findExistingResonance(
|
||||
center: { lat: number; lng: number },
|
||||
maxDistance: number
|
||||
): string | null {
|
||||
for (const [id, resonance] of this.resonances) {
|
||||
const dist = this.haversineDistance(
|
||||
center.lat,
|
||||
center.lng,
|
||||
resonance.center.lat,
|
||||
resonance.center.lng
|
||||
);
|
||||
if (dist <= maxDistance) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Maintenance
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Run network maintenance
|
||||
*/
|
||||
private runMaintenance(): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Remove expired signals
|
||||
for (const [id, signal] of this.activeSignals) {
|
||||
if (!isSignalAlive(signal, this.config.propagation)) {
|
||||
this.removeSignal(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Fade old node signals
|
||||
for (const node of this.nodes.values()) {
|
||||
// Decay signal strength over time
|
||||
const timeSinceActive = now - node.lastActiveAt;
|
||||
const decay = Math.exp(-timeSinceActive / 60000); // 1 minute half-life
|
||||
node.signalStrength *= decay;
|
||||
node.receivedSignal *= decay;
|
||||
}
|
||||
|
||||
// Remove stale resonances
|
||||
for (const [id, resonance] of this.resonances) {
|
||||
const age = now - resonance.updatedAt;
|
||||
if (age > this.config.resonance.timeWindow * 2) {
|
||||
this.resonances.delete(id);
|
||||
this.emit({ type: 'resonance:faded', resonanceId: id });
|
||||
}
|
||||
}
|
||||
|
||||
// Expire old nodes if configured
|
||||
if (this.config.nodeExpiration > 0) {
|
||||
for (const [id, node] of this.nodes) {
|
||||
if (now - node.lastActiveAt > this.config.nodeExpiration) {
|
||||
if (node.type === 'ghost') {
|
||||
this.removeNode(id);
|
||||
} else {
|
||||
// Convert to ghost
|
||||
node.type = 'ghost';
|
||||
this.emit({ type: 'node:updated', node });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect resonances
|
||||
this.detectResonance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update network statistics
|
||||
*/
|
||||
private updateStats(): void {
|
||||
const nodes = Array.from(this.nodes.values());
|
||||
const nodeCount = nodes.length;
|
||||
const hyphaCount = this.hyphae.size;
|
||||
|
||||
// Calculate density
|
||||
const possibleConnections = (nodeCount * (nodeCount - 1)) / 2;
|
||||
const density = possibleConnections > 0 ? hyphaCount / possibleConnections : 0;
|
||||
|
||||
// Calculate average strength
|
||||
const avgStrength =
|
||||
nodeCount > 0
|
||||
? nodes.reduce((s, n) => s + n.signalStrength + n.receivedSignal, 0) /
|
||||
nodeCount
|
||||
: 0;
|
||||
|
||||
// Find most active node
|
||||
let mostActiveNode: MyceliumNode | undefined;
|
||||
let maxActivity = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const activity = node.signalStrength + node.receivedSignal;
|
||||
if (activity > maxActivity) {
|
||||
maxActivity = activity;
|
||||
mostActiveNode = node;
|
||||
}
|
||||
}
|
||||
|
||||
// Find hottest area
|
||||
const hotNodes = nodes
|
||||
.filter((n) => n.position)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.signalStrength + b.receivedSignal - (a.signalStrength + a.receivedSignal)
|
||||
)
|
||||
.slice(0, 10);
|
||||
|
||||
const hottestArea =
|
||||
hotNodes.length > 0
|
||||
? {
|
||||
...this.calculateCentroid(hotNodes),
|
||||
strength: hotNodes[0].signalStrength + hotNodes[0].receivedSignal,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
this.stats = {
|
||||
nodeCount,
|
||||
hyphaCount,
|
||||
activeSignalCount: this.activeSignals.size,
|
||||
resonanceCount: this.resonances.size,
|
||||
avgNodeStrength: avgStrength,
|
||||
density,
|
||||
mostActiveNodeId: mostActiveNode?.id,
|
||||
hottestArea,
|
||||
};
|
||||
|
||||
this.emit({ type: 'network:stats-updated', stats: this.stats });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Event System
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to network events
|
||||
*/
|
||||
on(listener: MyceliumEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
*/
|
||||
private emit(event: MyceliumEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('Error in mycelium event listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Utilities
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Generate a unique ID
|
||||
*/
|
||||
private generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine distance between two points (meters)
|
||||
*/
|
||||
private haversineDistance(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number
|
||||
): number {
|
||||
const R = 6371000;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Serialization
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Export network to JSON
|
||||
*/
|
||||
export(): {
|
||||
nodes: MyceliumNode[];
|
||||
hyphae: Hypha[];
|
||||
signals: Signal[];
|
||||
resonances: Resonance[];
|
||||
} {
|
||||
return {
|
||||
nodes: Array.from(this.nodes.values()),
|
||||
hyphae: Array.from(this.hyphae.values()),
|
||||
signals: Array.from(this.activeSignals.values()),
|
||||
resonances: Array.from(this.resonances.values()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import network from JSON
|
||||
*/
|
||||
import(data: {
|
||||
nodes: MyceliumNode[];
|
||||
hyphae: Hypha[];
|
||||
signals?: Signal[];
|
||||
resonances?: Resonance[];
|
||||
}): void {
|
||||
this.nodes.clear();
|
||||
this.hyphae.clear();
|
||||
this.activeSignals.clear();
|
||||
this.resonances.clear();
|
||||
this.nodeSignals.clear();
|
||||
|
||||
for (const node of data.nodes) {
|
||||
this.nodes.set(node.id, node);
|
||||
this.nodeSignals.set(node.id, []);
|
||||
}
|
||||
|
||||
for (const hypha of data.hyphae) {
|
||||
this.hyphae.set(hypha.id, hypha);
|
||||
}
|
||||
|
||||
if (data.signals) {
|
||||
for (const signal of data.signals) {
|
||||
this.activeSignals.set(signal.id, signal);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.resonances) {
|
||||
for (const resonance of data.resonances) {
|
||||
this.resonances.set(resonance.id, resonance);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateStats();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a new mycelium network with default configuration
|
||||
*/
|
||||
export function createMyceliumNetwork(
|
||||
config?: Partial<NetworkConfig>
|
||||
): MyceliumNetwork {
|
||||
return new MyceliumNetwork(config);
|
||||
}
|
||||
|
|
@ -0,0 +1,629 @@
|
|||
/**
|
||||
* Signal Propagation System for Mycelial Network
|
||||
*
|
||||
* Implements biologically-inspired signal propagation through a network
|
||||
* of nodes connected by hyphae. Signals decay over distance, time, and
|
||||
* network topology.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Signal,
|
||||
SignalType,
|
||||
SignalEmissionConfig,
|
||||
MyceliumNode,
|
||||
Hypha,
|
||||
DecayConfig,
|
||||
DecayFunctionType,
|
||||
MultiDecayConfig,
|
||||
PropagationConfig,
|
||||
PropagationAlgorithm,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Decay Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Apply a decay function to calculate signal attenuation
|
||||
*/
|
||||
export function applyDecay(distance: number, config: DecayConfig): number {
|
||||
if (distance < 0) return 1;
|
||||
|
||||
switch (config.type) {
|
||||
case 'exponential':
|
||||
return Math.exp(-config.rate * distance);
|
||||
|
||||
case 'linear':
|
||||
return Math.max(0, 1 - config.rate * distance);
|
||||
|
||||
case 'inverse':
|
||||
return 1 / (1 + config.rate * distance);
|
||||
|
||||
case 'step':
|
||||
return distance < (config.threshold ?? 1) ? 1 : 0;
|
||||
|
||||
case 'gaussian':
|
||||
const sigma = config.sigma ?? 1;
|
||||
return Math.exp(-(distance * distance) / (2 * sigma * sigma));
|
||||
|
||||
case 'custom':
|
||||
if (config.customFn) {
|
||||
return config.customFn(distance, config);
|
||||
}
|
||||
return 1;
|
||||
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combined decay from multiple factors
|
||||
*/
|
||||
export function calculateMultiDecay(
|
||||
distances: {
|
||||
spatial?: number;
|
||||
temporal?: number;
|
||||
relational?: number;
|
||||
topological?: number;
|
||||
},
|
||||
config: MultiDecayConfig
|
||||
): number {
|
||||
const factors: number[] = [];
|
||||
|
||||
if (distances.spatial !== undefined) {
|
||||
factors.push(applyDecay(distances.spatial, config.spatial));
|
||||
}
|
||||
|
||||
if (distances.temporal !== undefined) {
|
||||
factors.push(applyDecay(distances.temporal, config.temporal));
|
||||
}
|
||||
|
||||
if (distances.relational !== undefined) {
|
||||
factors.push(applyDecay(distances.relational, config.relational));
|
||||
}
|
||||
|
||||
if (distances.topological !== undefined) {
|
||||
factors.push(applyDecay(distances.topological, config.topological));
|
||||
}
|
||||
|
||||
if (factors.length === 0) return 1;
|
||||
|
||||
switch (config.combination) {
|
||||
case 'multiply':
|
||||
return factors.reduce((a, b) => a * b, 1);
|
||||
|
||||
case 'min':
|
||||
return Math.min(...factors);
|
||||
|
||||
case 'average':
|
||||
return factors.reduce((a, b) => a + b, 0) / factors.length;
|
||||
|
||||
case 'max':
|
||||
return Math.max(...factors);
|
||||
|
||||
default:
|
||||
return factors.reduce((a, b) => a * b, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default decay configuration
|
||||
*/
|
||||
export const DEFAULT_DECAY_CONFIG: MultiDecayConfig = {
|
||||
spatial: { type: 'inverse', rate: 0.001 }, // 1km = 50% strength
|
||||
temporal: { type: 'exponential', rate: 0.0001 }, // ~2 hours half-life
|
||||
relational: { type: 'linear', rate: 0.2 }, // 5 hops to zero
|
||||
topological: { type: 'inverse', rate: 0.5 }, // Each hop halves
|
||||
combination: 'multiply',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Signal Creation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generate a unique signal ID
|
||||
*/
|
||||
function generateSignalId(): string {
|
||||
return `sig-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new signal
|
||||
*/
|
||||
export function createSignal(
|
||||
sourceId: string,
|
||||
emitterId: string,
|
||||
config: SignalEmissionConfig
|
||||
): Signal {
|
||||
const strength = config.strength ?? 1;
|
||||
|
||||
return {
|
||||
id: generateSignalId(),
|
||||
type: config.type,
|
||||
initialStrength: strength,
|
||||
currentStrength: strength,
|
||||
sourceId,
|
||||
emitterId,
|
||||
emittedAt: Date.now(),
|
||||
hopCount: 0,
|
||||
path: [sourceId],
|
||||
payload: config.payload,
|
||||
ttl: config.ttl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a signal is still alive
|
||||
*/
|
||||
export function isSignalAlive(signal: Signal, config: PropagationConfig): boolean {
|
||||
// Check TTL
|
||||
if (signal.ttl !== null && Date.now() - signal.emittedAt > signal.ttl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check minimum strength
|
||||
if (signal.currentStrength < config.minStrength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check maximum hops
|
||||
if (signal.hopCount >= config.maxHops) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Propagation Algorithms
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Propagation result for a single step
|
||||
*/
|
||||
export interface PropagationStep {
|
||||
targetNodeId: string;
|
||||
signal: Signal;
|
||||
viaHyphaId: string;
|
||||
decayFactor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get neighbors of a node through its hyphae
|
||||
*/
|
||||
function getNeighbors(
|
||||
nodeId: string,
|
||||
nodes: Map<string, MyceliumNode>,
|
||||
hyphae: Map<string, Hypha>
|
||||
): Array<{ nodeId: string; hypha: Hypha }> {
|
||||
const node = nodes.get(nodeId);
|
||||
if (!node) return [];
|
||||
|
||||
const neighbors: Array<{ nodeId: string; hypha: Hypha }> = [];
|
||||
|
||||
for (const hyphaId of node.hyphae) {
|
||||
const hypha = hyphae.get(hyphaId);
|
||||
if (!hypha) continue;
|
||||
|
||||
// Find the other end of the hypha
|
||||
let otherId: string | null = null;
|
||||
if (hypha.sourceId === nodeId) {
|
||||
otherId = hypha.targetId;
|
||||
} else if (hypha.targetId === nodeId) {
|
||||
// Only follow if bidirectional
|
||||
if (!hypha.directed) {
|
||||
otherId = hypha.sourceId;
|
||||
}
|
||||
}
|
||||
|
||||
if (otherId && nodes.has(otherId)) {
|
||||
neighbors.push({ nodeId: otherId, hypha });
|
||||
}
|
||||
}
|
||||
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate spatial distance between two nodes
|
||||
*/
|
||||
function calculateSpatialDistance(
|
||||
node1: MyceliumNode,
|
||||
node2: MyceliumNode
|
||||
): number | undefined {
|
||||
if (node1.position && node2.position) {
|
||||
// Haversine distance in meters
|
||||
const R = 6371000;
|
||||
const lat1 = (node1.position.lat * Math.PI) / 180;
|
||||
const lat2 = (node2.position.lat * Math.PI) / 180;
|
||||
const dLat = lat2 - lat1;
|
||||
const dLng = ((node2.position.lng - node1.position.lng) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
if (node1.canvasPosition && node2.canvasPosition) {
|
||||
// Euclidean distance on canvas (arbitrary units)
|
||||
const dx = node2.canvasPosition.x - node1.canvasPosition.x;
|
||||
const dy = node2.canvasPosition.y - node1.canvasPosition.y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flood propagation: signal spreads to all reachable nodes
|
||||
*/
|
||||
export function propagateFlood(
|
||||
signal: Signal,
|
||||
currentNodeId: string,
|
||||
nodes: Map<string, MyceliumNode>,
|
||||
hyphae: Map<string, Hypha>,
|
||||
config: PropagationConfig,
|
||||
visited: Set<string> = new Set()
|
||||
): PropagationStep[] {
|
||||
const steps: PropagationStep[] = [];
|
||||
const currentNode = nodes.get(currentNodeId);
|
||||
if (!currentNode) return steps;
|
||||
|
||||
visited.add(currentNodeId);
|
||||
|
||||
const neighbors = getNeighbors(currentNodeId, nodes, hyphae);
|
||||
|
||||
for (const { nodeId: neighborId, hypha } of neighbors) {
|
||||
// Skip already visited
|
||||
if (visited.has(neighborId)) continue;
|
||||
|
||||
const neighborNode = nodes.get(neighborId);
|
||||
if (!neighborNode) continue;
|
||||
|
||||
// Calculate decay
|
||||
const spatialDist = calculateSpatialDistance(currentNode, neighborNode);
|
||||
const temporalDist = Date.now() - signal.emittedAt;
|
||||
|
||||
const decayFactor = calculateMultiDecay(
|
||||
{
|
||||
spatial: spatialDist,
|
||||
temporal: temporalDist,
|
||||
topological: signal.hopCount + 1,
|
||||
},
|
||||
config.decay
|
||||
);
|
||||
|
||||
// Apply hypha conductance
|
||||
const effectiveDecay = decayFactor * hypha.conductance;
|
||||
|
||||
// Calculate new strength
|
||||
const newStrength = signal.currentStrength * effectiveDecay;
|
||||
|
||||
// Check if signal is still viable
|
||||
if (newStrength < config.minStrength) continue;
|
||||
|
||||
// Create propagated signal
|
||||
const propagatedSignal: Signal = {
|
||||
...signal,
|
||||
currentStrength: newStrength,
|
||||
hopCount: signal.hopCount + 1,
|
||||
path: [...signal.path, neighborId],
|
||||
};
|
||||
|
||||
steps.push({
|
||||
targetNodeId: neighborId,
|
||||
signal: propagatedSignal,
|
||||
viaHyphaId: hypha.id,
|
||||
decayFactor: effectiveDecay,
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gradient propagation: signal follows strongest connections
|
||||
*/
|
||||
export function propagateGradient(
|
||||
signal: Signal,
|
||||
currentNodeId: string,
|
||||
nodes: Map<string, MyceliumNode>,
|
||||
hyphae: Map<string, Hypha>,
|
||||
config: PropagationConfig,
|
||||
visited: Set<string> = new Set()
|
||||
): PropagationStep[] {
|
||||
const currentNode = nodes.get(currentNodeId);
|
||||
if (!currentNode) return [];
|
||||
|
||||
visited.add(currentNodeId);
|
||||
|
||||
const neighbors = getNeighbors(currentNodeId, nodes, hyphae);
|
||||
|
||||
// Score each neighbor
|
||||
const scored = neighbors
|
||||
.filter(({ nodeId }) => !visited.has(nodeId))
|
||||
.map(({ nodeId, hypha }) => {
|
||||
const neighborNode = nodes.get(nodeId);
|
||||
if (!neighborNode) return null;
|
||||
|
||||
const spatialDist = calculateSpatialDistance(currentNode, neighborNode);
|
||||
const decayFactor =
|
||||
calculateMultiDecay(
|
||||
{
|
||||
spatial: spatialDist,
|
||||
topological: signal.hopCount + 1,
|
||||
},
|
||||
config.decay
|
||||
) * hypha.conductance;
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
hypha,
|
||||
neighborNode,
|
||||
score: decayFactor * neighborNode.signalStrength,
|
||||
decayFactor,
|
||||
};
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Follow top path(s)
|
||||
const steps: PropagationStep[] = [];
|
||||
const topCount = Math.max(1, Math.floor(scored.length * 0.3)); // Top 30%
|
||||
|
||||
for (let i = 0; i < topCount && i < scored.length; i++) {
|
||||
const { nodeId, hypha, decayFactor } = scored[i];
|
||||
const newStrength = signal.currentStrength * decayFactor;
|
||||
|
||||
if (newStrength < config.minStrength) continue;
|
||||
|
||||
const propagatedSignal: Signal = {
|
||||
...signal,
|
||||
currentStrength: newStrength,
|
||||
hopCount: signal.hopCount + 1,
|
||||
path: [...signal.path, nodeId],
|
||||
};
|
||||
|
||||
steps.push({
|
||||
targetNodeId: nodeId,
|
||||
signal: propagatedSignal,
|
||||
viaHyphaId: hypha.id,
|
||||
decayFactor,
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Random walk propagation: probabilistic path following
|
||||
*/
|
||||
export function propagateRandomWalk(
|
||||
signal: Signal,
|
||||
currentNodeId: string,
|
||||
nodes: Map<string, MyceliumNode>,
|
||||
hyphae: Map<string, Hypha>,
|
||||
config: PropagationConfig,
|
||||
visited: Set<string> = new Set()
|
||||
): PropagationStep[] {
|
||||
const currentNode = nodes.get(currentNodeId);
|
||||
if (!currentNode) return [];
|
||||
|
||||
visited.add(currentNodeId);
|
||||
|
||||
const neighbors = getNeighbors(currentNodeId, nodes, hyphae).filter(
|
||||
({ nodeId }) => !visited.has(nodeId)
|
||||
);
|
||||
|
||||
if (neighbors.length === 0) return [];
|
||||
|
||||
// Calculate weights based on hypha strength and conductance
|
||||
const weights = neighbors.map(({ hypha }) => hypha.strength * hypha.conductance);
|
||||
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (totalWeight === 0) return [];
|
||||
|
||||
// Probabilistic selection
|
||||
const rand = Math.random() * totalWeight;
|
||||
let cumulative = 0;
|
||||
let selectedIndex = 0;
|
||||
|
||||
for (let i = 0; i < weights.length; i++) {
|
||||
cumulative += weights[i];
|
||||
if (rand <= cumulative) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { nodeId, hypha } = neighbors[selectedIndex];
|
||||
const neighborNode = nodes.get(nodeId);
|
||||
if (!neighborNode) return [];
|
||||
|
||||
const spatialDist = calculateSpatialDistance(currentNode, neighborNode);
|
||||
const decayFactor =
|
||||
calculateMultiDecay(
|
||||
{
|
||||
spatial: spatialDist,
|
||||
topological: signal.hopCount + 1,
|
||||
},
|
||||
config.decay
|
||||
) * hypha.conductance;
|
||||
|
||||
const newStrength = signal.currentStrength * decayFactor;
|
||||
|
||||
if (newStrength < config.minStrength) return [];
|
||||
|
||||
const propagatedSignal: Signal = {
|
||||
...signal,
|
||||
currentStrength: newStrength,
|
||||
hopCount: signal.hopCount + 1,
|
||||
path: [...signal.path, nodeId],
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
targetNodeId: nodeId,
|
||||
signal: propagatedSignal,
|
||||
viaHyphaId: hypha.id,
|
||||
decayFactor,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Diffusion propagation: signal spreads like heat/concentration gradient
|
||||
*/
|
||||
export function propagateDiffusion(
|
||||
signal: Signal,
|
||||
currentNodeId: string,
|
||||
nodes: Map<string, MyceliumNode>,
|
||||
hyphae: Map<string, Hypha>,
|
||||
config: PropagationConfig,
|
||||
_visited: Set<string> = new Set()
|
||||
): PropagationStep[] {
|
||||
const currentNode = nodes.get(currentNodeId);
|
||||
if (!currentNode) return [];
|
||||
|
||||
const neighbors = getNeighbors(currentNodeId, nodes, hyphae);
|
||||
const steps: PropagationStep[] = [];
|
||||
|
||||
// Distribute signal equally weighted by conductance
|
||||
const totalConductance = neighbors.reduce(
|
||||
(sum, { hypha }) => sum + hypha.conductance,
|
||||
0
|
||||
);
|
||||
|
||||
if (totalConductance === 0) return [];
|
||||
|
||||
for (const { nodeId, hypha } of neighbors) {
|
||||
const neighborNode = nodes.get(nodeId);
|
||||
if (!neighborNode) continue;
|
||||
|
||||
// Fraction of signal that goes this way
|
||||
const fraction = hypha.conductance / totalConductance;
|
||||
const spatialDist = calculateSpatialDistance(currentNode, neighborNode);
|
||||
|
||||
const decayFactor =
|
||||
calculateMultiDecay(
|
||||
{
|
||||
spatial: spatialDist,
|
||||
topological: signal.hopCount + 1,
|
||||
},
|
||||
config.decay
|
||||
) * fraction;
|
||||
|
||||
const newStrength = signal.currentStrength * decayFactor;
|
||||
|
||||
if (newStrength < config.minStrength) continue;
|
||||
|
||||
const propagatedSignal: Signal = {
|
||||
...signal,
|
||||
currentStrength: newStrength,
|
||||
hopCount: signal.hopCount + 1,
|
||||
path: [...signal.path, nodeId],
|
||||
};
|
||||
|
||||
steps.push({
|
||||
targetNodeId: nodeId,
|
||||
signal: propagatedSignal,
|
||||
viaHyphaId: hypha.id,
|
||||
decayFactor,
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main propagation dispatcher
|
||||
*/
|
||||
export function propagateSignal(
|
||||
signal: Signal,
|
||||
currentNodeId: string,
|
||||
nodes: Map<string, MyceliumNode>,
|
||||
hyphae: Map<string, Hypha>,
|
||||
config: PropagationConfig,
|
||||
visited: Set<string> = new Set()
|
||||
): PropagationStep[] {
|
||||
switch (config.algorithm) {
|
||||
case 'flood':
|
||||
return propagateFlood(signal, currentNodeId, nodes, hyphae, config, visited);
|
||||
|
||||
case 'gradient':
|
||||
return propagateGradient(signal, currentNodeId, nodes, hyphae, config, visited);
|
||||
|
||||
case 'random-walk':
|
||||
return propagateRandomWalk(signal, currentNodeId, nodes, hyphae, config, visited);
|
||||
|
||||
case 'diffusion':
|
||||
return propagateDiffusion(signal, currentNodeId, nodes, hyphae, config, visited);
|
||||
|
||||
case 'shortest-path':
|
||||
// For shortest path, we'd need target(s) specified
|
||||
// Fall back to flood for now
|
||||
return propagateFlood(signal, currentNodeId, nodes, hyphae, config, visited);
|
||||
|
||||
default:
|
||||
return propagateFlood(signal, currentNodeId, nodes, hyphae, config, visited);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Aggregation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Aggregate multiple signals at a node
|
||||
*/
|
||||
export function aggregateSignals(
|
||||
signals: Signal[],
|
||||
method: 'sum' | 'max' | 'average' | 'weighted-average' = 'sum'
|
||||
): number {
|
||||
if (signals.length === 0) return 0;
|
||||
|
||||
const strengths = signals.map((s) => s.currentStrength);
|
||||
|
||||
switch (method) {
|
||||
case 'sum':
|
||||
return Math.min(1, strengths.reduce((a, b) => a + b, 0));
|
||||
|
||||
case 'max':
|
||||
return Math.max(...strengths);
|
||||
|
||||
case 'average':
|
||||
return strengths.reduce((a, b) => a + b, 0) / strengths.length;
|
||||
|
||||
case 'weighted-average':
|
||||
// Weight by recency
|
||||
const now = Date.now();
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
for (const signal of signals) {
|
||||
const age = now - signal.emittedAt;
|
||||
const weight = Math.exp(-age / 60000); // 1 minute decay
|
||||
totalWeight += weight;
|
||||
weightedSum += signal.currentStrength * weight;
|
||||
}
|
||||
|
||||
return totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
|
||||
default:
|
||||
return Math.min(1, strengths.reduce((a, b) => a + b, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default propagation configuration
|
||||
*/
|
||||
export const DEFAULT_PROPAGATION_CONFIG: PropagationConfig = {
|
||||
algorithm: 'flood',
|
||||
maxHops: 10,
|
||||
minStrength: 0.01,
|
||||
decay: DEFAULT_DECAY_CONFIG,
|
||||
aggregate: true,
|
||||
aggregateFn: 'sum',
|
||||
};
|
||||
|
|
@ -0,0 +1,507 @@
|
|||
/**
|
||||
* Mycelial Network Type Definitions
|
||||
*
|
||||
* A biologically-inspired signal propagation system for collaborative spaces.
|
||||
* Models how information, attention, and value flow through the network
|
||||
* like nutrients through mycelium.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Core Node Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Types of nodes in the mycelial network
|
||||
*/
|
||||
export type NodeType =
|
||||
| 'poi' // Point of interest (location, landmark)
|
||||
| 'event' // Temporal event (meeting, activity)
|
||||
| 'person' // User/participant
|
||||
| 'resource' // Shared resource (document, tool)
|
||||
| 'discovery' // New finding/insight
|
||||
| 'waypoint' // Route waypoint
|
||||
| 'cluster' // Aggregated group of nodes
|
||||
| 'ghost'; // Historical/fading node
|
||||
|
||||
/**
|
||||
* A node in the mycelial network
|
||||
*/
|
||||
export interface MyceliumNode {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Node type */
|
||||
type: NodeType;
|
||||
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
|
||||
/** Geographic position (optional for non-spatial nodes) */
|
||||
position?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
|
||||
/** Canvas position (for canvas-native nodes) */
|
||||
canvasPosition?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
/** Timestamp when node was created */
|
||||
createdAt: number;
|
||||
|
||||
/** Timestamp when node was last active */
|
||||
lastActiveAt: number;
|
||||
|
||||
/** Node's current signal strength (0-1) */
|
||||
signalStrength: number;
|
||||
|
||||
/** Node's accumulated signal from network (0-1) */
|
||||
receivedSignal: number;
|
||||
|
||||
/** Metadata */
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
/** Connected hyphal IDs */
|
||||
hyphae: string[];
|
||||
|
||||
/** Owner/creator user ID */
|
||||
ownerId?: string;
|
||||
|
||||
/** Tags for categorization */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Connection Types (Hyphae)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Types of connections between nodes
|
||||
*/
|
||||
export type HyphaType =
|
||||
| 'route' // Physical route/path
|
||||
| 'attention' // Attention thread
|
||||
| 'reference' // Hyperlink/reference
|
||||
| 'temporal' // Time-based connection
|
||||
| 'social' // Social relationship
|
||||
| 'causal' // Cause-effect relationship
|
||||
| 'proximity' // Geographic proximity
|
||||
| 'semantic'; // Semantic similarity
|
||||
|
||||
/**
|
||||
* A hypha (connection) in the network
|
||||
*/
|
||||
export interface Hypha {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Connection type */
|
||||
type: HyphaType;
|
||||
|
||||
/** Source node ID */
|
||||
sourceId: string;
|
||||
|
||||
/** Target node ID */
|
||||
targetId: string;
|
||||
|
||||
/** Connection strength (0-1) */
|
||||
strength: number;
|
||||
|
||||
/** Directional? (false = bidirectional) */
|
||||
directed: boolean;
|
||||
|
||||
/** Signal transmission efficiency (0-1) */
|
||||
conductance: number;
|
||||
|
||||
/** When this connection was established */
|
||||
createdAt: number;
|
||||
|
||||
/** Last time a signal passed through */
|
||||
lastSignalAt?: number;
|
||||
|
||||
/** Metadata */
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Types of signals that propagate through the network
|
||||
*/
|
||||
export type SignalType =
|
||||
| 'urgency' // Time-sensitive alert
|
||||
| 'discovery' // New finding
|
||||
| 'attention' // Focus/interest
|
||||
| 'trust' // Trust/reputation
|
||||
| 'novelty' // Something new/unusual
|
||||
| 'activity' // Recent activity indicator
|
||||
| 'request' // Request for help/input
|
||||
| 'presence' // Someone is here
|
||||
| 'custom'; // User-defined signal
|
||||
|
||||
/**
|
||||
* A signal propagating through the network
|
||||
*/
|
||||
export interface Signal {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Signal type */
|
||||
type: SignalType;
|
||||
|
||||
/** Original strength at emission (0-1) */
|
||||
initialStrength: number;
|
||||
|
||||
/** Current strength after propagation (0-1) */
|
||||
currentStrength: number;
|
||||
|
||||
/** Source node ID */
|
||||
sourceId: string;
|
||||
|
||||
/** User who emitted the signal */
|
||||
emitterId: string;
|
||||
|
||||
/** When the signal was emitted */
|
||||
emittedAt: number;
|
||||
|
||||
/** How many hops from source */
|
||||
hopCount: number;
|
||||
|
||||
/** Path of node IDs the signal has traveled */
|
||||
path: string[];
|
||||
|
||||
/** Custom payload data */
|
||||
payload?: unknown;
|
||||
|
||||
/** Time-to-live in milliseconds (null = no expiry) */
|
||||
ttl: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for signal emission
|
||||
*/
|
||||
export interface SignalEmissionConfig {
|
||||
/** Signal type */
|
||||
type: SignalType;
|
||||
|
||||
/** Initial strength (0-1) */
|
||||
strength?: number;
|
||||
|
||||
/** Payload data */
|
||||
payload?: unknown;
|
||||
|
||||
/** Time-to-live in ms */
|
||||
ttl?: number;
|
||||
|
||||
/** Maximum hops before signal dies */
|
||||
maxHops?: number;
|
||||
|
||||
/** Minimum strength to continue propagation */
|
||||
minStrength?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Decay Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Decay function types
|
||||
*/
|
||||
export type DecayFunctionType =
|
||||
| 'exponential' // e^(-k*d)
|
||||
| 'linear' // max(0, 1 - k*d)
|
||||
| 'inverse' // 1 / (1 + k*d)
|
||||
| 'step' // 1 if d < threshold, 0 otherwise
|
||||
| 'gaussian' // e^(-d^2 / 2*sigma^2)
|
||||
| 'custom'; // User-defined function
|
||||
|
||||
/**
|
||||
* Configuration for decay functions
|
||||
*/
|
||||
export interface DecayConfig {
|
||||
/** Decay function type */
|
||||
type: DecayFunctionType;
|
||||
|
||||
/** Decay rate constant */
|
||||
rate: number;
|
||||
|
||||
/** Threshold for step function */
|
||||
threshold?: number;
|
||||
|
||||
/** Sigma for gaussian */
|
||||
sigma?: number;
|
||||
|
||||
/** Custom decay function */
|
||||
customFn?: (distance: number, config: DecayConfig) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-dimensional decay configuration
|
||||
*/
|
||||
export interface MultiDecayConfig {
|
||||
/** Spatial decay (geographic distance) */
|
||||
spatial: DecayConfig;
|
||||
|
||||
/** Temporal decay (time since emission) */
|
||||
temporal: DecayConfig;
|
||||
|
||||
/** Relational decay (social/trust distance) */
|
||||
relational: DecayConfig;
|
||||
|
||||
/** Topological decay (network hops) */
|
||||
topological: DecayConfig;
|
||||
|
||||
/** How to combine decay factors */
|
||||
combination: 'multiply' | 'min' | 'average' | 'max';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Propagation Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Propagation algorithm type
|
||||
*/
|
||||
export type PropagationAlgorithm =
|
||||
| 'flood' // Flood fill to all reachable nodes
|
||||
| 'gradient' // Follow strongest connections
|
||||
| 'random-walk' // Random walk with bias
|
||||
| 'shortest-path' // Shortest path to targets
|
||||
| 'diffusion'; // Diffusion-based spreading
|
||||
|
||||
/**
|
||||
* Propagation configuration
|
||||
*/
|
||||
export interface PropagationConfig {
|
||||
/** Algorithm to use */
|
||||
algorithm: PropagationAlgorithm;
|
||||
|
||||
/** Maximum hops from source */
|
||||
maxHops: number;
|
||||
|
||||
/** Minimum signal strength to continue */
|
||||
minStrength: number;
|
||||
|
||||
/** Decay configuration */
|
||||
decay: MultiDecayConfig;
|
||||
|
||||
/** Whether to aggregate signals at nodes */
|
||||
aggregate: boolean;
|
||||
|
||||
/** Aggregation function */
|
||||
aggregateFn?: 'sum' | 'max' | 'average' | 'weighted-average';
|
||||
|
||||
/** Rate limiting (signals per second) */
|
||||
rateLimit?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Resonance Detection
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A detected resonance pattern (multiple users focusing on same area)
|
||||
*/
|
||||
export interface Resonance {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Center point of resonance */
|
||||
center: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
|
||||
/** Radius of resonance area (meters) */
|
||||
radius: number;
|
||||
|
||||
/** Users contributing to this resonance */
|
||||
participants: string[];
|
||||
|
||||
/** Strength of resonance (0-1) */
|
||||
strength: number;
|
||||
|
||||
/** When resonance was first detected */
|
||||
detectedAt: number;
|
||||
|
||||
/** When resonance was last updated */
|
||||
updatedAt: number;
|
||||
|
||||
/** Whether participants are connected socially */
|
||||
isSerendipitous: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resonance detection configuration
|
||||
*/
|
||||
export interface ResonanceConfig {
|
||||
/** Minimum participants for resonance */
|
||||
minParticipants: number;
|
||||
|
||||
/** Maximum distance between participants (meters) */
|
||||
maxDistance: number;
|
||||
|
||||
/** Time window for activity (ms) */
|
||||
timeWindow: number;
|
||||
|
||||
/** Minimum strength to report */
|
||||
minStrength: number;
|
||||
|
||||
/** Whether to detect only serendipitous (unconnected) resonance */
|
||||
serendipitousOnly: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Visualization Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Visualization style for nodes
|
||||
*/
|
||||
export interface NodeVisualization {
|
||||
/** Base color */
|
||||
color: string;
|
||||
|
||||
/** Size based on signal strength */
|
||||
size: number;
|
||||
|
||||
/** Opacity based on age/relevance */
|
||||
opacity: number;
|
||||
|
||||
/** Pulsing animation if active */
|
||||
pulse: boolean;
|
||||
|
||||
/** Glow effect for high signal */
|
||||
glow: boolean;
|
||||
|
||||
/** Icon to display */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization style for hyphae
|
||||
*/
|
||||
export interface HyphaVisualization {
|
||||
/** Color (often gradient based on conductance) */
|
||||
color: string;
|
||||
|
||||
/** Stroke width based on strength */
|
||||
strokeWidth: number;
|
||||
|
||||
/** Opacity */
|
||||
opacity: number;
|
||||
|
||||
/** Animated flow direction */
|
||||
flowAnimation: boolean;
|
||||
|
||||
/** Dash pattern for different types */
|
||||
dashPattern?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization style for signals
|
||||
*/
|
||||
export interface SignalVisualization {
|
||||
/** Color by signal type */
|
||||
color: string;
|
||||
|
||||
/** Particle size */
|
||||
particleSize: number;
|
||||
|
||||
/** Animation speed */
|
||||
speed: number;
|
||||
|
||||
/** Trail effect */
|
||||
trail: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Network State
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Complete network state
|
||||
*/
|
||||
export interface MyceliumNetworkState {
|
||||
/** All nodes in the network */
|
||||
nodes: Map<string, MyceliumNode>;
|
||||
|
||||
/** All hyphae in the network */
|
||||
hyphae: Map<string, Hypha>;
|
||||
|
||||
/** Active signals */
|
||||
activeSignals: Map<string, Signal>;
|
||||
|
||||
/** Detected resonances */
|
||||
resonances: Map<string, Resonance>;
|
||||
|
||||
/** Network statistics */
|
||||
stats: NetworkStats;
|
||||
|
||||
/** Last update timestamp */
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network statistics
|
||||
*/
|
||||
export interface NetworkStats {
|
||||
/** Total node count */
|
||||
nodeCount: number;
|
||||
|
||||
/** Total hypha count */
|
||||
hyphaCount: number;
|
||||
|
||||
/** Active signal count */
|
||||
activeSignalCount: number;
|
||||
|
||||
/** Resonance count */
|
||||
resonanceCount: number;
|
||||
|
||||
/** Average node signal strength */
|
||||
avgNodeStrength: number;
|
||||
|
||||
/** Network density (hyphae / possible connections) */
|
||||
density: number;
|
||||
|
||||
/** Most active node */
|
||||
mostActiveNodeId?: string;
|
||||
|
||||
/** Hottest area (highest signal concentration) */
|
||||
hottestArea?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
strength: number;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Event Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Events emitted by the mycelium network
|
||||
*/
|
||||
export type MyceliumEvent =
|
||||
| { type: 'node:created'; node: MyceliumNode }
|
||||
| { type: 'node:updated'; node: MyceliumNode }
|
||||
| { type: 'node:removed'; nodeId: string }
|
||||
| { type: 'hypha:created'; hypha: Hypha }
|
||||
| { type: 'hypha:updated'; hypha: Hypha }
|
||||
| { type: 'hypha:removed'; hyphaId: string }
|
||||
| { type: 'signal:emitted'; signal: Signal }
|
||||
| { type: 'signal:propagated'; signal: Signal; toNodeId: string }
|
||||
| { type: 'signal:expired'; signalId: string }
|
||||
| { type: 'resonance:detected'; resonance: Resonance }
|
||||
| { type: 'resonance:updated'; resonance: Resonance }
|
||||
| { type: 'resonance:faded'; resonanceId: string }
|
||||
| { type: 'network:stats-updated'; stats: NetworkStats };
|
||||
|
||||
/**
|
||||
* Event listener function
|
||||
*/
|
||||
export type MyceliumEventListener = (event: MyceliumEvent) => void;
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
/**
|
||||
* Mycelium Network Visualization
|
||||
*
|
||||
* Helpers for visualizing the mycelial network on a canvas or map.
|
||||
* Provides colors, sizes, and styles based on node/signal state.
|
||||
*/
|
||||
|
||||
import type {
|
||||
MyceliumNode,
|
||||
NodeType,
|
||||
Hypha,
|
||||
HyphaType,
|
||||
Signal,
|
||||
SignalType,
|
||||
Resonance,
|
||||
NodeVisualization,
|
||||
HyphaVisualization,
|
||||
SignalVisualization,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Color Palettes
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Node type colors (nature-inspired)
|
||||
*/
|
||||
export const NODE_COLORS: Record<NodeType, string> = {
|
||||
poi: '#4ade80', // Green - points of interest
|
||||
event: '#f59e0b', // Amber - temporal events
|
||||
person: '#3b82f6', // Blue - people
|
||||
resource: '#8b5cf6', // Purple - resources
|
||||
discovery: '#ec4899', // Pink - discoveries
|
||||
waypoint: '#06b6d4', // Cyan - waypoints
|
||||
cluster: '#f97316', // Orange - clusters
|
||||
ghost: '#6b7280', // Gray - fading nodes
|
||||
};
|
||||
|
||||
/**
|
||||
* Signal type colors (energy/urgency inspired)
|
||||
*/
|
||||
export const SIGNAL_COLORS: Record<SignalType, string> = {
|
||||
urgency: '#ef4444', // Red - urgent
|
||||
discovery: '#10b981', // Emerald - new finding
|
||||
attention: '#f59e0b', // Amber - focus
|
||||
trust: '#3b82f6', // Blue - trust
|
||||
novelty: '#ec4899', // Pink - novel
|
||||
activity: '#84cc16', // Lime - activity
|
||||
request: '#a855f7', // Purple - request
|
||||
presence: '#06b6d4', // Cyan - presence
|
||||
custom: '#6b7280', // Gray - custom
|
||||
};
|
||||
|
||||
/**
|
||||
* Hypha type colors
|
||||
*/
|
||||
export const HYPHA_COLORS: Record<HyphaType, string> = {
|
||||
route: '#22c55e', // Green - routes
|
||||
attention: '#f59e0b', // Amber - attention
|
||||
reference: '#8b5cf6', // Purple - references
|
||||
temporal: '#06b6d4', // Cyan - temporal
|
||||
social: '#3b82f6', // Blue - social
|
||||
causal: '#f97316', // Orange - causal
|
||||
proximity: '#84cc16', // Lime - proximity
|
||||
semantic: '#ec4899', // Pink - semantic
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Node Visualization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get visualization properties for a node
|
||||
*/
|
||||
export function getNodeVisualization(node: MyceliumNode): NodeVisualization {
|
||||
const baseColor = NODE_COLORS[node.type] || NODE_COLORS.poi;
|
||||
|
||||
// Size based on signal strength (8-32px)
|
||||
const totalSignal = node.signalStrength + node.receivedSignal;
|
||||
const size = 8 + Math.min(24, totalSignal * 24);
|
||||
|
||||
// Opacity based on age (fade over time)
|
||||
const age = Date.now() - node.lastActiveAt;
|
||||
const ageFactor = Math.exp(-age / 3600000); // 1 hour half-life
|
||||
const opacity = 0.3 + 0.7 * ageFactor;
|
||||
|
||||
// Pulse if recently active
|
||||
const pulse = age < 5000;
|
||||
|
||||
// Glow if high signal
|
||||
const glow = totalSignal > 0.5;
|
||||
|
||||
// Icon based on type
|
||||
const icons: Partial<Record<NodeType, string>> = {
|
||||
poi: '📍',
|
||||
event: '📅',
|
||||
person: '👤',
|
||||
resource: '📦',
|
||||
discovery: '💡',
|
||||
waypoint: '🔵',
|
||||
cluster: '🔷',
|
||||
ghost: '👻',
|
||||
};
|
||||
|
||||
return {
|
||||
color: baseColor,
|
||||
size,
|
||||
opacity,
|
||||
pulse,
|
||||
glow,
|
||||
icon: icons[node.type],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS style object for a node
|
||||
*/
|
||||
export function getNodeStyle(node: MyceliumNode): React.CSSProperties {
|
||||
const viz = getNodeVisualization(node);
|
||||
|
||||
return {
|
||||
width: viz.size,
|
||||
height: viz.size,
|
||||
backgroundColor: viz.color,
|
||||
opacity: viz.opacity,
|
||||
borderRadius: '50%',
|
||||
boxShadow: viz.glow
|
||||
? `0 0 ${viz.size / 2}px ${viz.color}80`
|
||||
: undefined,
|
||||
animation: viz.pulse ? 'pulse 1s ease-in-out infinite' : undefined,
|
||||
position: 'absolute' as const,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hypha Visualization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get visualization properties for a hypha
|
||||
*/
|
||||
export function getHyphaVisualization(hypha: Hypha): HyphaVisualization {
|
||||
const baseColor = HYPHA_COLORS[hypha.type] || HYPHA_COLORS.proximity;
|
||||
|
||||
// Stroke width based on strength (1-6px)
|
||||
const strokeWidth = 1 + hypha.strength * 5;
|
||||
|
||||
// Opacity based on conductance
|
||||
const opacity = 0.2 + hypha.conductance * 0.8;
|
||||
|
||||
// Animate if recently used
|
||||
const recentlyUsed = hypha.lastSignalAt
|
||||
? Date.now() - hypha.lastSignalAt < 5000
|
||||
: false;
|
||||
|
||||
// Dash pattern for different types
|
||||
const dashPatterns: Partial<Record<HyphaType, number[]>> = {
|
||||
temporal: [5, 5],
|
||||
reference: [2, 2],
|
||||
semantic: [10, 5],
|
||||
};
|
||||
|
||||
return {
|
||||
color: baseColor,
|
||||
strokeWidth,
|
||||
opacity,
|
||||
flowAnimation: recentlyUsed,
|
||||
dashPattern: dashPatterns[hypha.type],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SVG path attributes for a hypha
|
||||
*/
|
||||
export function getHyphaPathAttrs(
|
||||
hypha: Hypha
|
||||
): Record<string, string | number | undefined> {
|
||||
const viz = getHyphaVisualization(hypha);
|
||||
|
||||
return {
|
||||
stroke: viz.color,
|
||||
strokeWidth: viz.strokeWidth,
|
||||
strokeOpacity: viz.opacity,
|
||||
strokeDasharray: viz.dashPattern?.join(' '),
|
||||
fill: 'none',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Visualization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get visualization properties for a signal
|
||||
*/
|
||||
export function getSignalVisualization(signal: Signal): SignalVisualization {
|
||||
const baseColor = SIGNAL_COLORS[signal.type] || SIGNAL_COLORS.activity;
|
||||
|
||||
// Particle size based on strength (4-16px)
|
||||
const particleSize = 4 + signal.currentStrength * 12;
|
||||
|
||||
// Speed based on urgency
|
||||
const speedMultipliers: Partial<Record<SignalType, number>> = {
|
||||
urgency: 2,
|
||||
discovery: 1.5,
|
||||
attention: 1.2,
|
||||
activity: 1,
|
||||
};
|
||||
const speed = (speedMultipliers[signal.type] ?? 1) * 100; // px per second
|
||||
|
||||
return {
|
||||
color: baseColor,
|
||||
particleSize,
|
||||
speed,
|
||||
trail: signal.currentStrength > 0.3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS style for a signal particle
|
||||
*/
|
||||
export function getSignalParticleStyle(signal: Signal): React.CSSProperties {
|
||||
const viz = getSignalVisualization(signal);
|
||||
|
||||
return {
|
||||
width: viz.particleSize,
|
||||
height: viz.particleSize,
|
||||
backgroundColor: viz.color,
|
||||
borderRadius: '50%',
|
||||
boxShadow: `0 0 ${viz.particleSize}px ${viz.color}`,
|
||||
position: 'absolute' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Resonance Visualization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get visualization properties for a resonance
|
||||
*/
|
||||
export function getResonanceVisualization(resonance: Resonance): {
|
||||
color: string;
|
||||
radius: number;
|
||||
opacity: number;
|
||||
pulse: boolean;
|
||||
label: string;
|
||||
} {
|
||||
// Color based on whether serendipitous
|
||||
const color = resonance.isSerendipitous
|
||||
? '#ec4899' // Pink for serendipity
|
||||
: '#3b82f6'; // Blue for connected
|
||||
|
||||
// Radius from resonance radius (in meters, convert for display)
|
||||
const radius = resonance.radius;
|
||||
|
||||
// Opacity based on strength
|
||||
const opacity = 0.1 + resonance.strength * 0.3;
|
||||
|
||||
// Age for pulsing
|
||||
const age = Date.now() - resonance.updatedAt;
|
||||
const pulse = age < 10000;
|
||||
|
||||
// Label
|
||||
const label = resonance.isSerendipitous
|
||||
? `${resonance.participants.length} converging`
|
||||
: `${resonance.participants.length} together`;
|
||||
|
||||
return {
|
||||
color,
|
||||
radius,
|
||||
opacity,
|
||||
pulse,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Gradient and Heat Map Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Interpolate between two colors
|
||||
*/
|
||||
export function interpolateColor(
|
||||
color1: string,
|
||||
color2: string,
|
||||
factor: number
|
||||
): string {
|
||||
// Parse hex colors
|
||||
const c1 = parseInt(color1.slice(1), 16);
|
||||
const c2 = parseInt(color2.slice(1), 16);
|
||||
|
||||
const r1 = (c1 >> 16) & 255;
|
||||
const g1 = (c1 >> 8) & 255;
|
||||
const b1 = c1 & 255;
|
||||
|
||||
const r2 = (c2 >> 16) & 255;
|
||||
const g2 = (c2 >> 8) & 255;
|
||||
const b2 = c2 & 255;
|
||||
|
||||
const r = Math.round(r1 + (r2 - r1) * factor);
|
||||
const g = Math.round(g1 + (g2 - g1) * factor);
|
||||
const b = Math.round(b1 + (b2 - b1) * factor);
|
||||
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heat map color for a value (0-1)
|
||||
*/
|
||||
export function getHeatMapColor(value: number): string {
|
||||
// Gradient: blue -> cyan -> green -> yellow -> red
|
||||
const colors = ['#3b82f6', '#06b6d4', '#22c55e', '#eab308', '#ef4444'];
|
||||
const segments = colors.length - 1;
|
||||
const segment = Math.min(segments - 1, Math.floor(value * segments));
|
||||
const localFactor = (value * segments) % 1;
|
||||
|
||||
return interpolateColor(colors[segment], colors[segment + 1], localFactor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strength color (cold to hot)
|
||||
*/
|
||||
export function getStrengthColor(strength: number): string {
|
||||
// Blue (cold) to Red (hot)
|
||||
return interpolateColor('#3b82f6', '#ef4444', Math.min(1, strength));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Canvas Rendering Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Draw a node on a canvas context
|
||||
*/
|
||||
export function drawNode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: MyceliumNode,
|
||||
x: number,
|
||||
y: number
|
||||
): void {
|
||||
const viz = getNodeVisualization(node);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = viz.opacity;
|
||||
|
||||
// Glow effect
|
||||
if (viz.glow) {
|
||||
ctx.shadowColor = viz.color;
|
||||
ctx.shadowBlur = viz.size / 2;
|
||||
}
|
||||
|
||||
// Draw circle
|
||||
ctx.fillStyle = viz.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, viz.size / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a hypha on a canvas context
|
||||
*/
|
||||
export function drawHypha(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
hypha: Hypha,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number
|
||||
): void {
|
||||
const viz = getHyphaVisualization(hypha);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = viz.opacity;
|
||||
ctx.strokeStyle = viz.color;
|
||||
ctx.lineWidth = viz.strokeWidth;
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
if (viz.dashPattern) {
|
||||
ctx.setLineDash(viz.dashPattern);
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw arrow for directed hyphae
|
||||
if (hypha.directed) {
|
||||
const angle = Math.atan2(y2 - y1, x2 - x1);
|
||||
const arrowSize = viz.strokeWidth * 3;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(
|
||||
x2 - arrowSize * Math.cos(angle - Math.PI / 6),
|
||||
y2 - arrowSize * Math.sin(angle - Math.PI / 6)
|
||||
);
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(
|
||||
x2 - arrowSize * Math.cos(angle + Math.PI / 6),
|
||||
y2 - arrowSize * Math.sin(angle + Math.PI / 6)
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a resonance circle on a canvas context
|
||||
*/
|
||||
export function drawResonance(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
resonance: Resonance,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radiusPx: number
|
||||
): void {
|
||||
const viz = getResonanceVisualization(resonance);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = viz.opacity;
|
||||
|
||||
// Fill
|
||||
ctx.fillStyle = viz.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radiusPx, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Border
|
||||
ctx.globalAlpha = viz.opacity * 2;
|
||||
ctx.strokeStyle = viz.color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Animation Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate position along a path for animated signals
|
||||
*/
|
||||
export function getSignalPosition(
|
||||
signal: Signal,
|
||||
pathPoints: Array<{ x: number; y: number }>,
|
||||
animationTime: number
|
||||
): { x: number; y: number } | null {
|
||||
if (pathPoints.length < 2) return null;
|
||||
|
||||
const viz = getSignalVisualization(signal);
|
||||
const elapsed = animationTime - signal.emittedAt;
|
||||
const totalLength = calculatePathLength(pathPoints);
|
||||
const distance = (elapsed / 1000) * viz.speed;
|
||||
|
||||
if (distance >= totalLength) return null;
|
||||
|
||||
// Find segment
|
||||
let accumulated = 0;
|
||||
for (let i = 0; i < pathPoints.length - 1; i++) {
|
||||
const segmentLength = calculateDistance(pathPoints[i], pathPoints[i + 1]);
|
||||
if (accumulated + segmentLength >= distance) {
|
||||
const segmentProgress = (distance - accumulated) / segmentLength;
|
||||
return {
|
||||
x: pathPoints[i].x + (pathPoints[i + 1].x - pathPoints[i].x) * segmentProgress,
|
||||
y: pathPoints[i].y + (pathPoints[i + 1].y - pathPoints[i].y) * segmentProgress,
|
||||
};
|
||||
}
|
||||
accumulated += segmentLength;
|
||||
}
|
||||
|
||||
return pathPoints[pathPoints.length - 1];
|
||||
}
|
||||
|
||||
function calculatePathLength(points: Array<{ x: number; y: number }>): number {
|
||||
let length = 0;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
length += calculateDistance(points[i], points[i + 1]);
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
function calculateDistance(
|
||||
p1: { x: number; y: number },
|
||||
p2: { x: number; y: number }
|
||||
): number {
|
||||
return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CSS Keyframes (for React/CSS animations)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CSS keyframes for pulse animation
|
||||
*/
|
||||
export const PULSE_KEYFRAMES = `
|
||||
@keyframes pulse {
|
||||
0% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||
50% { transform: translate(-50%, -50%) scale(1.2); opacity: 0.7; }
|
||||
100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* CSS keyframes for flow animation on hyphae
|
||||
*/
|
||||
export const FLOW_KEYFRAMES = `
|
||||
@keyframes flow {
|
||||
0% { stroke-dashoffset: 20; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* CSS keyframes for resonance ripple
|
||||
*/
|
||||
export const RIPPLE_KEYFRAMES = `
|
||||
@keyframes ripple {
|
||||
0% { transform: scale(0.8); opacity: 0.8; }
|
||||
100% { transform: scale(1.2); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,468 @@
|
|||
/**
|
||||
* Presence Layer Component
|
||||
*
|
||||
* Renders location presence indicators on the canvas/map.
|
||||
* Shows other users with uncertainty circles based on trust-level precision.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import type { PresenceView } from './types';
|
||||
import type { PresenceIndicatorData } from './useLocationPresence';
|
||||
import { viewsToIndicators } from './useLocationPresence';
|
||||
import { getRadiusForPrecision, TRUST_LEVEL_PRECISION } from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface PresenceLayerProps {
|
||||
/** Presence views to render */
|
||||
views: PresenceView[];
|
||||
|
||||
/** Map projection function (lat/lng to screen coordinates) */
|
||||
project: (lat: number, lng: number) => { x: number; y: number };
|
||||
|
||||
/** Current zoom level (for scaling indicators) */
|
||||
zoom: number;
|
||||
|
||||
/** Whether to show uncertainty circles */
|
||||
showUncertainty?: boolean;
|
||||
|
||||
/** Whether to show direction arrows */
|
||||
showDirection?: boolean;
|
||||
|
||||
/** Whether to show names */
|
||||
showNames?: boolean;
|
||||
|
||||
/** Click handler for presence indicators */
|
||||
onIndicatorClick?: (indicator: PresenceIndicatorData) => void;
|
||||
|
||||
/** Hover handler */
|
||||
onIndicatorHover?: (indicator: PresenceIndicatorData | null) => void;
|
||||
|
||||
/** Custom render function for indicators */
|
||||
renderIndicator?: (indicator: PresenceIndicatorData, screenPos: { x: number; y: number }) => React.ReactNode;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function PresenceLayer({
|
||||
views,
|
||||
project,
|
||||
zoom,
|
||||
showUncertainty = true,
|
||||
showDirection = true,
|
||||
showNames = true,
|
||||
onIndicatorClick,
|
||||
onIndicatorHover,
|
||||
renderIndicator,
|
||||
}: PresenceLayerProps) {
|
||||
// Convert views to indicator data
|
||||
const indicators = useMemo(() => viewsToIndicators(views), [views]);
|
||||
|
||||
// Calculate screen positions
|
||||
const positioned = useMemo(() => {
|
||||
return indicators.map((indicator) => ({
|
||||
indicator,
|
||||
screenPos: project(indicator.position.lat, indicator.position.lng),
|
||||
}));
|
||||
}, [indicators, project]);
|
||||
|
||||
if (positioned.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="presence-layer" style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||
{positioned.map(({ indicator, screenPos }) => {
|
||||
if (renderIndicator) {
|
||||
return (
|
||||
<div key={indicator.id} style={{ position: 'absolute', left: screenPos.x, top: screenPos.y, transform: 'translate(-50%, -50%)' }}>
|
||||
{renderIndicator(indicator, screenPos)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PresenceIndicator
|
||||
key={indicator.id}
|
||||
indicator={indicator}
|
||||
screenPos={screenPos}
|
||||
zoom={zoom}
|
||||
showUncertainty={showUncertainty}
|
||||
showDirection={showDirection}
|
||||
showName={showNames}
|
||||
onClick={onIndicatorClick}
|
||||
onHover={onIndicatorHover}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Presence Indicator Component
|
||||
// =============================================================================
|
||||
|
||||
interface PresenceIndicatorProps {
|
||||
indicator: PresenceIndicatorData;
|
||||
screenPos: { x: number; y: number };
|
||||
zoom: number;
|
||||
showUncertainty: boolean;
|
||||
showDirection: boolean;
|
||||
showName: boolean;
|
||||
onClick?: (indicator: PresenceIndicatorData) => void;
|
||||
onHover?: (indicator: PresenceIndicatorData | null) => void;
|
||||
}
|
||||
|
||||
function PresenceIndicator({
|
||||
indicator,
|
||||
screenPos,
|
||||
zoom,
|
||||
showUncertainty,
|
||||
showDirection,
|
||||
showName,
|
||||
onClick,
|
||||
onHover,
|
||||
}: PresenceIndicatorProps) {
|
||||
// Calculate uncertainty circle radius in pixels
|
||||
// This is approximate - would need proper map projection for accuracy
|
||||
const metersPerPixel = 156543.03392 * Math.cos((indicator.position.lat * Math.PI) / 180) / Math.pow(2, zoom);
|
||||
const uncertaintyPixels = indicator.uncertaintyRadius / metersPerPixel;
|
||||
|
||||
// Clamp uncertainty circle size
|
||||
const clampedUncertainty = Math.min(Math.max(uncertaintyPixels, 20), 200);
|
||||
|
||||
// Status-based opacity
|
||||
const opacity = indicator.status === 'online' ? 1 : indicator.status === 'away' ? 0.7 : 0.4;
|
||||
|
||||
// Moving animation
|
||||
const isAnimated = indicator.isMoving;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="presence-indicator"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: screenPos.x,
|
||||
top: screenPos.y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'auto',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
opacity,
|
||||
}}
|
||||
onClick={() => onClick?.(indicator)}
|
||||
onMouseEnter={() => onHover?.(indicator)}
|
||||
onMouseLeave={() => onHover?.(null)}
|
||||
>
|
||||
{/* Uncertainty circle */}
|
||||
{showUncertainty && (
|
||||
<div
|
||||
className="uncertainty-circle"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: clampedUncertainty * 2,
|
||||
height: clampedUncertainty * 2,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `${indicator.color}20`,
|
||||
border: `2px solid ${indicator.color}40`,
|
||||
animation: isAnimated ? 'pulse 2s ease-in-out infinite' : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Direction arrow */}
|
||||
{showDirection && indicator.heading !== undefined && indicator.isMoving && (
|
||||
<div
|
||||
className="direction-arrow"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: `translate(-50%, -50%) rotate(${indicator.heading}deg)`,
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderBottom: `20px solid ${indicator.color}`,
|
||||
marginTop: -15,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Center dot */}
|
||||
<div
|
||||
className="center-dot"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: indicator.color,
|
||||
border: '3px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{/* Verified badge */}
|
||||
{indicator.isVerified && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#22c55e',
|
||||
border: '1px solid white',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name label */}
|
||||
{showName && (
|
||||
<div
|
||||
className="name-label"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '100%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginTop: 8,
|
||||
padding: '2px 8px',
|
||||
backgroundColor: 'rgba(0,0,0,0.75)',
|
||||
color: 'white',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 120,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{indicator.displayName}
|
||||
<TrustBadge level={indicator.trustLevel} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Trust Badge Component
|
||||
// =============================================================================
|
||||
|
||||
interface TrustBadgeProps {
|
||||
level: PresenceIndicatorData['trustLevel'];
|
||||
}
|
||||
|
||||
function TrustBadge({ level }: TrustBadgeProps) {
|
||||
const badges: Record<string, { icon: string; color: string }> = {
|
||||
intimate: { icon: '♥', color: '#ec4899' },
|
||||
close: { icon: '★', color: '#f59e0b' },
|
||||
friends: { icon: '●', color: '#22c55e' },
|
||||
network: { icon: '◐', color: '#3b82f6' },
|
||||
public: { icon: '○', color: '#6b7280' },
|
||||
};
|
||||
|
||||
const badge = badges[level] ?? badges.public;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: badge.color,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{badge.icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Presence List Component
|
||||
// =============================================================================
|
||||
|
||||
export interface PresenceListProps {
|
||||
views: PresenceView[];
|
||||
onUserClick?: (view: PresenceView) => void;
|
||||
onTrustLevelChange?: (pubKey: string, level: PresenceView['trustLevel']) => void;
|
||||
}
|
||||
|
||||
export function PresenceList({ views, onUserClick, onTrustLevelChange }: PresenceListProps) {
|
||||
const sortedViews = useMemo(() => {
|
||||
return [...views].sort((a, b) => {
|
||||
// Online first, then by proximity
|
||||
if (a.status !== b.status) {
|
||||
const statusOrder = { online: 0, away: 1, busy: 2, invisible: 3, offline: 4 };
|
||||
return statusOrder[a.status] - statusOrder[b.status];
|
||||
}
|
||||
if (a.proximity && b.proximity) {
|
||||
const distOrder = { here: 0, nearby: 1, 'same-area': 2, 'same-city': 3, far: 4 };
|
||||
return distOrder[a.proximity.category] - distOrder[b.proximity.category];
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [views]);
|
||||
|
||||
if (sortedViews.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 16, color: '#6b7280', textAlign: 'center' }}>
|
||||
No other users nearby
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="presence-list" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sortedViews.map((view) => (
|
||||
<PresenceListItem
|
||||
key={view.user.pubKey}
|
||||
view={view}
|
||||
onClick={() => onUserClick?.(view)}
|
||||
onTrustLevelChange={onTrustLevelChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PresenceListItemProps {
|
||||
view: PresenceView;
|
||||
onClick?: () => void;
|
||||
onTrustLevelChange?: (pubKey: string, level: PresenceView['trustLevel']) => void;
|
||||
}
|
||||
|
||||
function PresenceListItem({ view, onClick, onTrustLevelChange }: PresenceListItemProps) {
|
||||
const proximityLabels = {
|
||||
here: 'Right here',
|
||||
nearby: 'Nearby',
|
||||
'same-area': 'Same area',
|
||||
'same-city': 'Same city',
|
||||
far: 'Far away',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
online: '#22c55e',
|
||||
away: '#f59e0b',
|
||||
busy: '#ef4444',
|
||||
invisible: '#6b7280',
|
||||
offline: '#374151',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: view.user.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{view.user.displayName.charAt(0).toUpperCase()}
|
||||
{/* Status dot */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: statusColors[view.status],
|
||||
border: '2px solid #1f2937',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{view.user.displayName}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af' }}>
|
||||
{view.proximity ? proximityLabels[view.proximity.category] : 'Location unknown'}
|
||||
{view.location?.isMoving && ' • Moving'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust level selector */}
|
||||
{onTrustLevelChange && (
|
||||
<select
|
||||
value={view.trustLevel}
|
||||
onChange={(e) => onTrustLevelChange(view.user.pubKey, e.target.value as PresenceView['trustLevel'])}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #374151',
|
||||
backgroundColor: '#1f2937',
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="network">Network</option>
|
||||
<option value="friends">Friends</option>
|
||||
<option value="close">Close</option>
|
||||
<option value="intimate">Intimate</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CSS Keyframes (inject once)
|
||||
// =============================================================================
|
||||
|
||||
const styleId = 'presence-layer-styles';
|
||||
if (typeof document !== 'undefined' && !document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* Real-Time Location Presence System
|
||||
*
|
||||
* Privacy-preserving location sharing for collaborative mapping.
|
||||
* Each user's location is shared at different precision levels
|
||||
* based on their trust circle configuration.
|
||||
*
|
||||
* Features:
|
||||
* - zkGPS commitment-based location hiding
|
||||
* - Trust circle precision controls (intimate → public)
|
||||
* - Real-time broadcasting and receiving
|
||||
* - Proximity detection without exact location
|
||||
* - React hooks for easy integration
|
||||
* - Map visualization components
|
||||
*
|
||||
* IMPORTANT: Location sharing is OPT-IN by default. Users must explicitly
|
||||
* click "Share Location" to start broadcasting. GPS is never accessed
|
||||
* without user consent.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import { useLocationPresence, PresenceLayer } from './presence';
|
||||
*
|
||||
* function MapWithPresence() {
|
||||
* const presence = useLocationPresence({
|
||||
* channelId: 'my-map-room',
|
||||
* user: {
|
||||
* pubKey: myPublicKey,
|
||||
* privKey: myPrivateKey,
|
||||
* displayName: 'Alice',
|
||||
* color: '#3b82f6',
|
||||
* },
|
||||
* broadcastFn: (data) => sendToNetwork(data),
|
||||
* // autoStartLocation: false (DEFAULT - location is OPT-IN)
|
||||
* });
|
||||
*
|
||||
* // Handle incoming broadcasts from network
|
||||
* useEffect(() => {
|
||||
* const unsub = subscribeToNetwork((msg) => {
|
||||
* if (msg.type === 'location-presence') {
|
||||
* presence.handleBroadcast(msg.payload);
|
||||
* }
|
||||
* });
|
||||
* return unsub;
|
||||
* }, [presence.handleBroadcast]);
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <Map>
|
||||
* <PresenceLayer
|
||||
* views={presence.views}
|
||||
* project={(lat, lng) => map.project([lng, lat])}
|
||||
* zoom={map.getZoom()}
|
||||
* />
|
||||
* </Map>
|
||||
*
|
||||
* {/* Location sharing toggle - user must opt-in *\/}
|
||||
* {!presence.isSharing ? (
|
||||
* <button onClick={presence.startSharing}>
|
||||
* Share My Location (zkGPS)
|
||||
* </button>
|
||||
* ) : (
|
||||
* <button onClick={presence.stopSharing}>
|
||||
* Stop Sharing
|
||||
* </button>
|
||||
* )}
|
||||
*
|
||||
* <PresenceList
|
||||
* views={presence.views}
|
||||
* onTrustLevelChange={presence.setTrustLevel}
|
||||
* />
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
UserPresence,
|
||||
LocationPresence,
|
||||
PresenceStatus,
|
||||
LocationSource,
|
||||
PresenceBroadcast,
|
||||
LocationBroadcastPayload,
|
||||
StatusBroadcastPayload,
|
||||
ProximityBroadcastPayload,
|
||||
PrecisionLevel,
|
||||
PresenceView,
|
||||
ViewableLocation,
|
||||
ProximityInfo,
|
||||
PresenceChannelConfig,
|
||||
PresenceChannelState,
|
||||
PresenceEvent,
|
||||
PresenceEventListener,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
DEFAULT_PRESENCE_CONFIG,
|
||||
GEOHASH_PRECISION_RADIUS,
|
||||
TRUST_LEVEL_PRECISION,
|
||||
getRadiusForPrecision,
|
||||
getPrecisionForTrustLevel,
|
||||
} from './types';
|
||||
|
||||
// Manager
|
||||
export {
|
||||
PresenceManager,
|
||||
createPresenceManager,
|
||||
} from './manager';
|
||||
|
||||
// React hook
|
||||
export {
|
||||
useLocationPresence,
|
||||
viewsToIndicators,
|
||||
type UseLocationPresenceConfig,
|
||||
type UseLocationPresenceReturn,
|
||||
type PresenceIndicatorData,
|
||||
} from './useLocationPresence';
|
||||
|
||||
// Components
|
||||
export {
|
||||
PresenceLayer,
|
||||
PresenceList,
|
||||
type PresenceLayerProps,
|
||||
type PresenceListProps,
|
||||
} from './PresenceLayer';
|
||||
|
|
@ -0,0 +1,813 @@
|
|||
/**
|
||||
* Presence Manager
|
||||
*
|
||||
* Manages real-time location sharing with privacy controls.
|
||||
* Integrates with zkGPS for commitments and trust circles for
|
||||
* precision-based sharing.
|
||||
*/
|
||||
|
||||
import type {
|
||||
UserPresence,
|
||||
LocationPresence,
|
||||
PresenceStatus,
|
||||
PresenceBroadcast,
|
||||
LocationBroadcastPayload,
|
||||
StatusBroadcastPayload,
|
||||
ProximityBroadcastPayload,
|
||||
PrecisionLevel,
|
||||
PresenceView,
|
||||
ViewableLocation,
|
||||
ProximityInfo,
|
||||
PresenceChannelConfig,
|
||||
PresenceChannelState,
|
||||
PresenceEvent,
|
||||
PresenceEventListener,
|
||||
LocationSource,
|
||||
} from './types';
|
||||
import {
|
||||
DEFAULT_PRESENCE_CONFIG,
|
||||
TRUST_LEVEL_PRECISION,
|
||||
getRadiusForPrecision,
|
||||
getPrecisionForTrustLevel,
|
||||
} from './types';
|
||||
import type { TrustLevel, GeohashCommitment } from '../privacy/types';
|
||||
import { TrustCircleManager, createTrustCircleManager } from '../privacy/trustCircles';
|
||||
import { createCommitment } from '../privacy/commitments';
|
||||
import { encodeGeohash, decodeGeohash, getGeohashBounds } from '../privacy/geohash';
|
||||
|
||||
// =============================================================================
|
||||
// Presence Manager
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Manages presence for a channel
|
||||
*/
|
||||
export class PresenceManager {
|
||||
private config: PresenceChannelConfig;
|
||||
private state: PresenceChannelState;
|
||||
private trustCircles: TrustCircleManager;
|
||||
private listeners: Set<PresenceEventListener> = new Set();
|
||||
private updateTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private locationWatchId: number | null = null;
|
||||
private lastLocationUpdate: number = 0;
|
||||
private broadcastCallback: ((broadcast: PresenceBroadcast) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
config: Partial<PresenceChannelConfig> & Pick<PresenceChannelConfig, 'channelId' | 'userPubKey' | 'userPrivKey' | 'displayName' | 'color'>
|
||||
) {
|
||||
this.config = {
|
||||
...DEFAULT_PRESENCE_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
this.trustCircles = createTrustCircleManager(this.config.userPubKey);
|
||||
|
||||
this.state = {
|
||||
config: this.config,
|
||||
self: {
|
||||
pubKey: this.config.userPubKey,
|
||||
displayName: this.config.displayName,
|
||||
color: this.config.color,
|
||||
location: null,
|
||||
status: 'online',
|
||||
lastSeen: new Date(),
|
||||
isMoving: false,
|
||||
deviceType: this.detectDeviceType(),
|
||||
},
|
||||
others: new Map(),
|
||||
views: new Map(),
|
||||
connectionState: 'connecting',
|
||||
lastSequence: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lifecycle
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Start presence sharing
|
||||
*/
|
||||
start(broadcastCallback: (broadcast: PresenceBroadcast) => void): void {
|
||||
this.broadcastCallback = broadcastCallback;
|
||||
this.state.connectionState = 'connected';
|
||||
|
||||
// Start periodic presence updates
|
||||
this.updateTimer = setInterval(() => {
|
||||
this.broadcastPresence();
|
||||
}, this.config.updateInterval);
|
||||
|
||||
// Broadcast initial presence
|
||||
this.broadcastPresence();
|
||||
|
||||
this.emit({ type: 'connection:changed', state: 'connected' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop presence sharing
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.updateTimer) {
|
||||
clearInterval(this.updateTimer);
|
||||
this.updateTimer = null;
|
||||
}
|
||||
|
||||
this.stopLocationWatch();
|
||||
|
||||
// Broadcast leave message
|
||||
if (this.broadcastCallback) {
|
||||
this.broadcastCallback(this.createBroadcast('leave', null));
|
||||
}
|
||||
|
||||
this.state.connectionState = 'disconnected';
|
||||
this.emit({ type: 'connection:changed', state: 'disconnected' });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Location Sharing
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Start watching device location
|
||||
*/
|
||||
startLocationWatch(): void {
|
||||
if (!navigator.geolocation) {
|
||||
console.warn('Geolocation not available');
|
||||
return;
|
||||
}
|
||||
|
||||
this.locationWatchId = navigator.geolocation.watchPosition(
|
||||
(position) => this.handleLocationUpdate(position),
|
||||
(error) => this.handleLocationError(error),
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 5000,
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching device location
|
||||
*/
|
||||
stopLocationWatch(): void {
|
||||
if (this.locationWatchId !== null && navigator.geolocation) {
|
||||
navigator.geolocation.clearWatch(this.locationWatchId);
|
||||
this.locationWatchId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle location update from device
|
||||
*/
|
||||
private async handleLocationUpdate(position: GeolocationPosition): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle updates
|
||||
if (now - this.lastLocationUpdate < this.config.locationThrottle) {
|
||||
return;
|
||||
}
|
||||
this.lastLocationUpdate = now;
|
||||
|
||||
const coords = position.coords;
|
||||
|
||||
// Determine if moving based on speed
|
||||
const isMoving = (coords.speed ?? 0) > 0.5; // > 0.5 m/s = moving
|
||||
|
||||
// Create zkGPS commitment for the location
|
||||
const geohash = encodeGeohash(coords.latitude, coords.longitude, 12);
|
||||
const commitment = await createCommitment(
|
||||
coords.latitude,
|
||||
coords.longitude,
|
||||
12,
|
||||
this.config.userPubKey,
|
||||
this.config.userPrivKey
|
||||
);
|
||||
|
||||
// Update self location
|
||||
this.state.self.location = {
|
||||
coordinates: {
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
altitude: coords.altitude ?? undefined,
|
||||
accuracy: coords.accuracy,
|
||||
heading: coords.heading ?? undefined,
|
||||
speed: coords.speed ?? undefined,
|
||||
},
|
||||
commitment,
|
||||
timestamp: new Date(position.timestamp),
|
||||
source: 'gps',
|
||||
isLive: true,
|
||||
};
|
||||
|
||||
this.state.self.isMoving = isMoving;
|
||||
this.state.self.lastSeen = new Date();
|
||||
|
||||
// Broadcast location update
|
||||
this.broadcastLocation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle location error
|
||||
*/
|
||||
private handleLocationError(error: GeolocationPositionError): void {
|
||||
console.warn('Location error:', error.message);
|
||||
this.emit({ type: 'error', error: `Location error: ${error.message}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually set location (for testing or manual input)
|
||||
*/
|
||||
async setLocation(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
source: LocationSource = 'manual'
|
||||
): Promise<void> {
|
||||
const commitment = await createCommitment(
|
||||
latitude,
|
||||
longitude,
|
||||
12,
|
||||
this.config.userPubKey,
|
||||
this.config.userPrivKey
|
||||
);
|
||||
|
||||
this.state.self.location = {
|
||||
coordinates: {
|
||||
latitude,
|
||||
longitude,
|
||||
},
|
||||
commitment,
|
||||
timestamp: new Date(),
|
||||
source,
|
||||
isLive: source === 'gps',
|
||||
};
|
||||
|
||||
this.state.self.lastSeen = new Date();
|
||||
this.broadcastLocation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear current location (stop sharing)
|
||||
*/
|
||||
clearLocation(): void {
|
||||
this.state.self.location = null;
|
||||
this.broadcastPresence();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Broadcasting
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Broadcast current presence
|
||||
*/
|
||||
private broadcastPresence(): void {
|
||||
if (!this.broadcastCallback) return;
|
||||
|
||||
if (this.state.self.location) {
|
||||
this.broadcastLocation();
|
||||
} else {
|
||||
this.broadcastStatus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast location update
|
||||
*/
|
||||
private broadcastLocation(): void {
|
||||
if (!this.broadcastCallback || !this.state.self.location) return;
|
||||
|
||||
const location = this.state.self.location;
|
||||
|
||||
// Create precision levels for each trust level
|
||||
const precisionLevels: PrecisionLevel[] = [];
|
||||
const fullGeohash = location.commitment.geohash;
|
||||
|
||||
for (const [level, precision] of Object.entries(TRUST_LEVEL_PRECISION)) {
|
||||
precisionLevels.push({
|
||||
trustLevel: level as TrustLevel,
|
||||
geohash: fullGeohash.substring(0, precision),
|
||||
precision,
|
||||
});
|
||||
}
|
||||
|
||||
const payload: LocationBroadcastPayload = {
|
||||
commitment: location.commitment,
|
||||
precisionLevels,
|
||||
isMoving: this.state.self.isMoving,
|
||||
heading: location.coordinates.heading,
|
||||
speedCategory: this.getSpeedCategory(location.coordinates.speed),
|
||||
};
|
||||
|
||||
const broadcast = this.createBroadcast('location', payload);
|
||||
this.broadcastCallback(broadcast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast status update
|
||||
*/
|
||||
private broadcastStatus(): void {
|
||||
if (!this.broadcastCallback) return;
|
||||
|
||||
const payload: StatusBroadcastPayload = {
|
||||
status: this.state.self.status,
|
||||
message: this.state.self.statusMessage,
|
||||
deviceType: this.state.self.deviceType,
|
||||
};
|
||||
|
||||
const broadcast = this.createBroadcast('status', payload);
|
||||
this.broadcastCallback(broadcast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a broadcast message
|
||||
*/
|
||||
private createBroadcast(
|
||||
type: PresenceBroadcast['type'],
|
||||
payload: PresenceBroadcast['payload']
|
||||
): PresenceBroadcast {
|
||||
this.state.lastSequence++;
|
||||
|
||||
return {
|
||||
senderPubKey: this.config.userPubKey,
|
||||
type,
|
||||
payload,
|
||||
signature: this.signBroadcast(type, payload),
|
||||
timestamp: new Date(),
|
||||
sequence: this.state.lastSequence,
|
||||
ttl: this.config.presenceTtl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a broadcast (simplified - in production use proper crypto)
|
||||
*/
|
||||
private signBroadcast(type: string, payload: any): string {
|
||||
const message = JSON.stringify({ type, payload, key: this.config.userPrivKey });
|
||||
// In production, use proper signing
|
||||
let hash = 0;
|
||||
for (let i = 0; i < message.length; i++) {
|
||||
hash = (hash << 5) - hash + message.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Receiving
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Handle incoming broadcast from another user
|
||||
*/
|
||||
handleBroadcast(broadcast: PresenceBroadcast): void {
|
||||
// Ignore our own broadcasts
|
||||
if (broadcast.senderPubKey === this.config.userPubKey) return;
|
||||
|
||||
// Check TTL
|
||||
const age = (Date.now() - broadcast.timestamp.getTime()) / 1000;
|
||||
if (age > broadcast.ttl) {
|
||||
return; // Expired
|
||||
}
|
||||
|
||||
switch (broadcast.type) {
|
||||
case 'location':
|
||||
this.handleLocationBroadcast(broadcast);
|
||||
break;
|
||||
case 'status':
|
||||
this.handleStatusBroadcast(broadcast);
|
||||
break;
|
||||
case 'proximity':
|
||||
this.handleProximityBroadcast(broadcast);
|
||||
break;
|
||||
case 'leave':
|
||||
this.handleLeaveBroadcast(broadcast);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle location broadcast
|
||||
*/
|
||||
private handleLocationBroadcast(broadcast: PresenceBroadcast): void {
|
||||
const payload = broadcast.payload as LocationBroadcastPayload;
|
||||
const senderKey = broadcast.senderPubKey;
|
||||
|
||||
// Get or create user presence
|
||||
let user = this.state.others.get(senderKey);
|
||||
const isNew = !user;
|
||||
|
||||
if (!user) {
|
||||
user = {
|
||||
pubKey: senderKey,
|
||||
displayName: senderKey.substring(0, 8) + '...',
|
||||
color: this.generateUserColor(senderKey),
|
||||
location: null,
|
||||
status: 'online',
|
||||
lastSeen: new Date(),
|
||||
isMoving: false,
|
||||
deviceType: 'unknown',
|
||||
};
|
||||
this.state.others.set(senderKey, user);
|
||||
}
|
||||
|
||||
// Update user's location (we store the commitment, not decoded location)
|
||||
user.location = {
|
||||
coordinates: { latitude: 0, longitude: 0 }, // We don't know exact coords
|
||||
commitment: payload.commitment,
|
||||
timestamp: broadcast.timestamp,
|
||||
source: 'network' as LocationSource,
|
||||
isLive: true,
|
||||
};
|
||||
user.isMoving = payload.isMoving;
|
||||
user.lastSeen = broadcast.timestamp;
|
||||
user.status = 'online';
|
||||
|
||||
// Create view for this user based on trust level
|
||||
const view = this.createPresenceView(user, payload);
|
||||
this.state.views.set(senderKey, view);
|
||||
|
||||
if (isNew) {
|
||||
this.emit({ type: 'user:joined', user });
|
||||
} else {
|
||||
this.emit({ type: 'user:updated', user, changes: ['location'] });
|
||||
}
|
||||
|
||||
if (view.location) {
|
||||
this.emit({ type: 'location:updated', pubKey: senderKey, location: view.location });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status broadcast
|
||||
*/
|
||||
private handleStatusBroadcast(broadcast: PresenceBroadcast): void {
|
||||
const payload = broadcast.payload as StatusBroadcastPayload;
|
||||
const senderKey = broadcast.senderPubKey;
|
||||
|
||||
let user = this.state.others.get(senderKey);
|
||||
if (!user) {
|
||||
user = {
|
||||
pubKey: senderKey,
|
||||
displayName: senderKey.substring(0, 8) + '...',
|
||||
color: this.generateUserColor(senderKey),
|
||||
location: null,
|
||||
status: payload.status,
|
||||
lastSeen: broadcast.timestamp,
|
||||
isMoving: false,
|
||||
deviceType: payload.deviceType ?? 'unknown',
|
||||
};
|
||||
this.state.others.set(senderKey, user);
|
||||
this.emit({ type: 'user:joined', user });
|
||||
} else {
|
||||
user.status = payload.status;
|
||||
user.statusMessage = payload.message;
|
||||
user.lastSeen = broadcast.timestamp;
|
||||
if (payload.deviceType) user.deviceType = payload.deviceType;
|
||||
this.emit({ type: 'status:changed', pubKey: senderKey, status: payload.status });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle proximity broadcast
|
||||
*/
|
||||
private handleProximityBroadcast(broadcast: PresenceBroadcast): void {
|
||||
const payload = broadcast.payload as ProximityBroadcastPayload;
|
||||
|
||||
// Only process if we're the target
|
||||
if (payload.targetPubKey !== this.config.userPubKey) return;
|
||||
|
||||
const senderKey = broadcast.senderPubKey;
|
||||
const view = this.state.views.get(senderKey);
|
||||
|
||||
if (view) {
|
||||
view.proximity = {
|
||||
category: payload.distanceCategory,
|
||||
verified: true, // Has proof
|
||||
mutuallyVisible: true,
|
||||
};
|
||||
|
||||
this.emit({ type: 'proximity:detected', pubKey: senderKey, proximity: view.proximity });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle leave broadcast
|
||||
*/
|
||||
private handleLeaveBroadcast(broadcast: PresenceBroadcast): void {
|
||||
const senderKey = broadcast.senderPubKey;
|
||||
|
||||
this.state.others.delete(senderKey);
|
||||
this.state.views.delete(senderKey);
|
||||
|
||||
this.emit({ type: 'user:left', pubKey: senderKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a presence view based on trust level
|
||||
*/
|
||||
private createPresenceView(
|
||||
user: UserPresence,
|
||||
payload: LocationBroadcastPayload
|
||||
): PresenceView {
|
||||
// Get trust level for this user
|
||||
const trustLevel = this.trustCircles.getTrustLevel(user.pubKey) ?? 'public';
|
||||
|
||||
// Find the precision level for our trust relationship
|
||||
const precisionLevel = payload.precisionLevels.find(
|
||||
(p) => p.trustLevel === trustLevel
|
||||
);
|
||||
|
||||
let location: ViewableLocation | null = null;
|
||||
|
||||
if (precisionLevel) {
|
||||
const geohash = precisionLevel.geohash;
|
||||
const bounds = getGeohashBounds(geohash);
|
||||
const center = {
|
||||
latitude: (bounds.minLat + bounds.maxLat) / 2,
|
||||
longitude: (bounds.minLng + bounds.maxLng) / 2,
|
||||
};
|
||||
|
||||
const ageSeconds = (Date.now() - payload.commitment.timestamp.getTime()) / 1000;
|
||||
|
||||
location = {
|
||||
geohash,
|
||||
precision: precisionLevel.precision,
|
||||
center,
|
||||
bounds,
|
||||
uncertaintyRadius: getRadiusForPrecision(precisionLevel.precision),
|
||||
ageSeconds,
|
||||
isMoving: payload.isMoving,
|
||||
heading: payload.heading,
|
||||
speedCategory: payload.speedCategory,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate proximity if we have our own location
|
||||
let proximity: ProximityInfo | undefined;
|
||||
if (location && this.state.self.location) {
|
||||
proximity = this.calculateProximity(location);
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
pubKey: user.pubKey,
|
||||
displayName: user.displayName,
|
||||
color: user.color,
|
||||
},
|
||||
location,
|
||||
status: user.status,
|
||||
lastSeen: user.lastSeen,
|
||||
trustLevel,
|
||||
isVerified: true, // Has commitment
|
||||
proximity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate proximity to another user
|
||||
*/
|
||||
private calculateProximity(otherLocation: ViewableLocation): ProximityInfo {
|
||||
if (!this.state.self.location) {
|
||||
return { category: 'far', verified: false, mutuallyVisible: false };
|
||||
}
|
||||
|
||||
const myCoords = this.state.self.location.coordinates;
|
||||
const distance = this.haversineDistance(
|
||||
myCoords.latitude,
|
||||
myCoords.longitude,
|
||||
otherLocation.center.latitude,
|
||||
otherLocation.center.longitude
|
||||
);
|
||||
|
||||
let category: ProximityInfo['category'];
|
||||
if (distance < 50) category = 'here';
|
||||
else if (distance < 500) category = 'nearby';
|
||||
else if (distance < 5000) category = 'same-area';
|
||||
else if (distance < 50000) category = 'same-city';
|
||||
else category = 'far';
|
||||
|
||||
return {
|
||||
category,
|
||||
verified: false,
|
||||
approximateMeters: distance,
|
||||
mutuallyVisible: distance < otherLocation.uncertaintyRadius * 2,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Trust Circle Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Set trust level for a contact
|
||||
*/
|
||||
setTrustLevel(pubKey: string, level: TrustLevel): void {
|
||||
this.trustCircles.setTrustLevel(pubKey, level);
|
||||
|
||||
// Update view if we have one
|
||||
const user = this.state.others.get(pubKey);
|
||||
if (user && user.location) {
|
||||
// Re-request their location at new precision
|
||||
// In a real implementation, this would request updated data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trust level for a contact
|
||||
*/
|
||||
getTrustLevel(pubKey: string): TrustLevel {
|
||||
return this.trustCircles.getTrustLevel(pubKey) ?? 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trust circles manager
|
||||
*/
|
||||
getTrustCircles(): TrustCircleManager {
|
||||
return this.trustCircles;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Status Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Set own status
|
||||
*/
|
||||
setStatus(status: PresenceStatus, message?: string): void {
|
||||
this.state.self.status = status;
|
||||
this.state.self.statusMessage = message;
|
||||
this.broadcastStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get own status
|
||||
*/
|
||||
getStatus(): PresenceStatus {
|
||||
return this.state.self.status;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Queries
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get all presence views
|
||||
*/
|
||||
getViews(): PresenceView[] {
|
||||
return Array.from(this.state.views.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get view for a specific user
|
||||
*/
|
||||
getView(pubKey: string): PresenceView | undefined {
|
||||
return this.state.views.get(pubKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all online users
|
||||
*/
|
||||
getOnlineUsers(): UserPresence[] {
|
||||
return Array.from(this.state.others.values()).filter(
|
||||
(u) => u.status === 'online' || u.status === 'away'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users within a distance category
|
||||
*/
|
||||
getUsersNearby(
|
||||
maxCategory: ProximityInfo['category'] = 'same-area'
|
||||
): PresenceView[] {
|
||||
const categories: ProximityInfo['category'][] = [
|
||||
'here',
|
||||
'nearby',
|
||||
'same-area',
|
||||
'same-city',
|
||||
'far',
|
||||
];
|
||||
const maxIndex = categories.indexOf(maxCategory);
|
||||
|
||||
return Array.from(this.state.views.values()).filter((v) => {
|
||||
if (!v.proximity) return false;
|
||||
const viewIndex = categories.indexOf(v.proximity.category);
|
||||
return viewIndex <= maxIndex;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getState(): PresenceChannelState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get own presence
|
||||
*/
|
||||
getSelf(): UserPresence {
|
||||
return this.state.self;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Events
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(listener: PresenceEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private emit(event: PresenceEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('Error in presence event listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Utilities
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Detect device type
|
||||
*/
|
||||
private detectDeviceType(): UserPresence['deviceType'] {
|
||||
if (typeof navigator === 'undefined') return 'unknown';
|
||||
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (/mobile|android|iphone|ipad|ipod/.test(ua)) {
|
||||
if (/ipad|tablet/.test(ua)) return 'tablet';
|
||||
return 'mobile';
|
||||
}
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get speed category from speed in m/s
|
||||
*/
|
||||
private getSpeedCategory(
|
||||
speed?: number
|
||||
): LocationBroadcastPayload['speedCategory'] {
|
||||
if (speed === undefined || speed < 0.5) return 'stationary';
|
||||
if (speed < 2) return 'walking';
|
||||
if (speed < 8) return 'cycling';
|
||||
if (speed < 50) return 'driving';
|
||||
return 'flying';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate color from public key
|
||||
*/
|
||||
private generateUserColor(pubKey: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < pubKey.length; i++) {
|
||||
hash = pubKey.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = hash % 360;
|
||||
return `hsl(${hue}, 70%, 50%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine distance calculation
|
||||
*/
|
||||
private haversineDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371000; // Earth radius in meters
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a presence manager
|
||||
*/
|
||||
export function createPresenceManager(
|
||||
config: Partial<PresenceChannelConfig> &
|
||||
Pick<PresenceChannelConfig, 'channelId' | 'userPubKey' | 'userPrivKey' | 'displayName' | 'color'>
|
||||
): PresenceManager {
|
||||
return new PresenceManager(config);
|
||||
}
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
/**
|
||||
* Real-Time Location Presence System
|
||||
*
|
||||
* Privacy-preserving location sharing for collaborative mapping.
|
||||
* Each user's location is shared at different precision levels
|
||||
* based on their trust circle configuration with other participants.
|
||||
*
|
||||
* Key concepts:
|
||||
* - LocationPresence: A user's current location with privacy controls
|
||||
* - PresenceView: How a user sees another user's location (precision varies)
|
||||
* - PresenceBroadcast: The data sent over the network (encrypted/committed)
|
||||
* - PresenceChannel: Real-time sync channel for presence updates
|
||||
*/
|
||||
|
||||
import type { TrustLevel, GeohashCommitment, ProximityProof } from '../privacy/types';
|
||||
|
||||
// =============================================================================
|
||||
// Location Presence
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A user's presence state
|
||||
*/
|
||||
export interface UserPresence {
|
||||
/** User's public key (identity) */
|
||||
pubKey: string;
|
||||
|
||||
/** Display name */
|
||||
displayName: string;
|
||||
|
||||
/** User's chosen color for map display */
|
||||
color: string;
|
||||
|
||||
/** Current location presence */
|
||||
location: LocationPresence | null;
|
||||
|
||||
/** Online status */
|
||||
status: PresenceStatus;
|
||||
|
||||
/** Last activity timestamp */
|
||||
lastSeen: Date;
|
||||
|
||||
/** Custom status message */
|
||||
statusMessage?: string;
|
||||
|
||||
/** Whether user is actively moving */
|
||||
isMoving: boolean;
|
||||
|
||||
/** Device type (for icon selection) */
|
||||
deviceType: 'mobile' | 'desktop' | 'tablet' | 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Online status
|
||||
*/
|
||||
export type PresenceStatus =
|
||||
| 'online' // Actively sharing location
|
||||
| 'away' // Online but inactive
|
||||
| 'busy' // Do not disturb
|
||||
| 'invisible' // Online but hidden
|
||||
| 'offline'; // Not connected
|
||||
|
||||
/**
|
||||
* A location with privacy controls
|
||||
*/
|
||||
export interface LocationPresence {
|
||||
/** Full precision coordinates (only stored locally) */
|
||||
coordinates: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
altitude?: number;
|
||||
accuracy?: number;
|
||||
heading?: number;
|
||||
speed?: number;
|
||||
};
|
||||
|
||||
/** zkGPS commitment for the location */
|
||||
commitment: GeohashCommitment;
|
||||
|
||||
/** Timestamp of this location reading */
|
||||
timestamp: Date;
|
||||
|
||||
/** Source of the location */
|
||||
source: LocationSource;
|
||||
|
||||
/** Whether this is a live/updating location */
|
||||
isLive: boolean;
|
||||
|
||||
/** Battery level (for mobile devices) */
|
||||
batteryLevel?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Location data sources
|
||||
*/
|
||||
export type LocationSource =
|
||||
| 'gps' // Device GPS
|
||||
| 'network' // Cell/WiFi triangulation
|
||||
| 'manual' // User-entered location
|
||||
| 'beacon' // BLE beacon
|
||||
| 'nfc' // NFC tag scan
|
||||
| 'ip' // IP geolocation
|
||||
| 'cached'; // Last known location
|
||||
|
||||
// =============================================================================
|
||||
// Presence Broadcasting
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Data broadcast over the network
|
||||
* Contains only commitment, not raw coordinates
|
||||
*/
|
||||
export interface PresenceBroadcast {
|
||||
/** Sender's public key */
|
||||
senderPubKey: string;
|
||||
|
||||
/** Message type */
|
||||
type: 'location' | 'status' | 'proximity' | 'leave';
|
||||
|
||||
/** Payload depends on type */
|
||||
payload: LocationBroadcastPayload | StatusBroadcastPayload | ProximityBroadcastPayload | null;
|
||||
|
||||
/** Signature from sender */
|
||||
signature: string;
|
||||
|
||||
/** Timestamp */
|
||||
timestamp: Date;
|
||||
|
||||
/** Sequence number (for ordering) */
|
||||
sequence: number;
|
||||
|
||||
/** TTL in seconds (for expiry) */
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Location broadcast payload
|
||||
*/
|
||||
export interface LocationBroadcastPayload {
|
||||
/** zkGPS commitment (hides exact location) */
|
||||
commitment: GeohashCommitment;
|
||||
|
||||
/** Geohash at various precision levels for different trust circles */
|
||||
precisionLevels: PrecisionLevel[];
|
||||
|
||||
/** Whether actively moving */
|
||||
isMoving: boolean;
|
||||
|
||||
/** Heading (if sharing) */
|
||||
heading?: number;
|
||||
|
||||
/** Speed category (not exact) */
|
||||
speedCategory?: 'stationary' | 'walking' | 'cycling' | 'driving' | 'flying';
|
||||
}
|
||||
|
||||
/**
|
||||
* Precision level for a trust circle
|
||||
*/
|
||||
export interface PrecisionLevel {
|
||||
/** Trust level this precision is for */
|
||||
trustLevel: TrustLevel;
|
||||
|
||||
/** Geohash at this precision (truncated) */
|
||||
geohash: string;
|
||||
|
||||
/** Precision (1-12 characters) */
|
||||
precision: number;
|
||||
|
||||
/** Encrypted full geohash for this trust level (optional) */
|
||||
encryptedGeohash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status broadcast payload
|
||||
*/
|
||||
export interface StatusBroadcastPayload {
|
||||
/** New status */
|
||||
status: PresenceStatus;
|
||||
|
||||
/** Status message */
|
||||
message?: string;
|
||||
|
||||
/** Device type */
|
||||
deviceType?: UserPresence['deviceType'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proximity broadcast payload
|
||||
* Proves proximity without revealing location
|
||||
*/
|
||||
export interface ProximityBroadcastPayload {
|
||||
/** Target user we're proving proximity to */
|
||||
targetPubKey: string;
|
||||
|
||||
/** Proximity proof */
|
||||
proof: ProximityProof;
|
||||
|
||||
/** Approximate distance category */
|
||||
distanceCategory: 'here' | 'nearby' | 'same-area' | 'same-city' | 'far';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Presence Views
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* How a user appears to another user
|
||||
* Precision depends on trust relationship
|
||||
*/
|
||||
export interface PresenceView {
|
||||
/** The user being viewed */
|
||||
user: {
|
||||
pubKey: string;
|
||||
displayName: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
/** Location at viewer's allowed precision */
|
||||
location: ViewableLocation | null;
|
||||
|
||||
/** Status */
|
||||
status: PresenceStatus;
|
||||
|
||||
/** Last seen */
|
||||
lastSeen: Date;
|
||||
|
||||
/** Trust level viewer has with this user */
|
||||
trustLevel: TrustLevel;
|
||||
|
||||
/** Whether location is verified (has valid commitment) */
|
||||
isVerified: boolean;
|
||||
|
||||
/** Proximity to viewer (if calculable) */
|
||||
proximity?: ProximityInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Location visible to a specific viewer
|
||||
*/
|
||||
export interface ViewableLocation {
|
||||
/** Geohash at allowed precision */
|
||||
geohash: string;
|
||||
|
||||
/** Precision level (1-12) */
|
||||
precision: number;
|
||||
|
||||
/** Approximate center of the geohash cell */
|
||||
center: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
/** Bounding box of the geohash cell */
|
||||
bounds: {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
};
|
||||
|
||||
/** Uncertainty radius in meters */
|
||||
uncertaintyRadius: number;
|
||||
|
||||
/** Age of the location in seconds */
|
||||
ageSeconds: number;
|
||||
|
||||
/** Whether actively moving */
|
||||
isMoving: boolean;
|
||||
|
||||
/** Direction of movement (if shared) */
|
||||
heading?: number;
|
||||
|
||||
/** Speed category */
|
||||
speedCategory?: LocationBroadcastPayload['speedCategory'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proximity information between two users
|
||||
*/
|
||||
export interface ProximityInfo {
|
||||
/** Distance category */
|
||||
category: ProximityBroadcastPayload['distanceCategory'];
|
||||
|
||||
/** Whether there's a verified proximity proof */
|
||||
verified: boolean;
|
||||
|
||||
/** Approximate distance in meters (if calculable) */
|
||||
approximateMeters?: number;
|
||||
|
||||
/** Can they see each other with current precision? */
|
||||
mutuallyVisible: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Presence Channel
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for presence channel
|
||||
*/
|
||||
export interface PresenceChannelConfig {
|
||||
/** Channel/room identifier */
|
||||
channelId: string;
|
||||
|
||||
/** User's public key */
|
||||
userPubKey: string;
|
||||
|
||||
/** User's private key for signing */
|
||||
userPrivKey: string;
|
||||
|
||||
/** Display name */
|
||||
displayName: string;
|
||||
|
||||
/** User color */
|
||||
color: string;
|
||||
|
||||
/** Update interval in milliseconds */
|
||||
updateInterval: number;
|
||||
|
||||
/** Location update throttle (min ms between updates) */
|
||||
locationThrottle: number;
|
||||
|
||||
/** Presence TTL in seconds */
|
||||
presenceTtl: number;
|
||||
|
||||
/** Whether to share location by default */
|
||||
shareLocationByDefault: boolean;
|
||||
|
||||
/** Default precision for public sharing */
|
||||
defaultPublicPrecision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default presence configuration
|
||||
*/
|
||||
export const DEFAULT_PRESENCE_CONFIG: Omit<PresenceChannelConfig, 'channelId' | 'userPubKey' | 'userPrivKey' | 'displayName' | 'color'> = {
|
||||
updateInterval: 5000, // 5 seconds
|
||||
locationThrottle: 1000, // 1 second minimum between location updates
|
||||
presenceTtl: 60, // 1 minute TTL
|
||||
shareLocationByDefault: false,
|
||||
defaultPublicPrecision: 4, // ~20km precision for public
|
||||
};
|
||||
|
||||
/**
|
||||
* Presence channel state
|
||||
*/
|
||||
export interface PresenceChannelState {
|
||||
/** Channel configuration */
|
||||
config: PresenceChannelConfig;
|
||||
|
||||
/** Our own presence */
|
||||
self: UserPresence;
|
||||
|
||||
/** Other users in the channel */
|
||||
others: Map<string, UserPresence>;
|
||||
|
||||
/** Views of other users (with our trust-based precision) */
|
||||
views: Map<string, PresenceView>;
|
||||
|
||||
/** Connection state */
|
||||
connectionState: 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
|
||||
|
||||
/** Last broadcast sequence number */
|
||||
lastSequence: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Events
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Presence events
|
||||
*/
|
||||
export type PresenceEvent =
|
||||
| { type: 'user:joined'; user: UserPresence }
|
||||
| { type: 'user:left'; pubKey: string }
|
||||
| { type: 'user:updated'; user: UserPresence; changes: string[] }
|
||||
| { type: 'location:updated'; pubKey: string; location: ViewableLocation }
|
||||
| { type: 'proximity:detected'; pubKey: string; proximity: ProximityInfo }
|
||||
| { type: 'status:changed'; pubKey: string; status: PresenceStatus }
|
||||
| { type: 'connection:changed'; state: PresenceChannelState['connectionState'] }
|
||||
| { type: 'error'; error: string };
|
||||
|
||||
export type PresenceEventListener = (event: PresenceEvent) => void;
|
||||
|
||||
// =============================================================================
|
||||
// Geohash Utilities
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Precision to approximate radius mapping
|
||||
*/
|
||||
export const GEOHASH_PRECISION_RADIUS: Record<number, number> = {
|
||||
1: 2500000, // ~2500km
|
||||
2: 630000, // ~630km
|
||||
3: 78000, // ~78km
|
||||
4: 20000, // ~20km
|
||||
5: 2400, // ~2.4km
|
||||
6: 610, // ~610m
|
||||
7: 76, // ~76m
|
||||
8: 19, // ~19m
|
||||
9: 2.4, // ~2.4m
|
||||
10: 0.6, // ~60cm
|
||||
11: 0.074, // ~7cm
|
||||
12: 0.019, // ~2cm
|
||||
};
|
||||
|
||||
/**
|
||||
* Trust level to default precision mapping
|
||||
*/
|
||||
export const TRUST_LEVEL_PRECISION: Record<TrustLevel, number> = {
|
||||
intimate: 9, // ~2.4m (very precise)
|
||||
close: 7, // ~76m (block level)
|
||||
friends: 5, // ~2.4km (neighborhood)
|
||||
network: 4, // ~20km (city area)
|
||||
public: 2, // ~630km (region only)
|
||||
};
|
||||
|
||||
/**
|
||||
* Get approximate radius for a precision level
|
||||
*/
|
||||
export function getRadiusForPrecision(precision: number): number {
|
||||
return GEOHASH_PRECISION_RADIUS[Math.min(12, Math.max(1, precision))] ?? 2500000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default precision for a trust level
|
||||
*/
|
||||
export function getPrecisionForTrustLevel(trustLevel: TrustLevel): number {
|
||||
return TRUST_LEVEL_PRECISION[trustLevel];
|
||||
}
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
/**
|
||||
* React Hook for Location Presence
|
||||
*
|
||||
* Provides real-time location sharing with privacy controls
|
||||
* for use in the tldraw canvas.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import type {
|
||||
UserPresence,
|
||||
PresenceView,
|
||||
PresenceStatus,
|
||||
PresenceEvent,
|
||||
PresenceBroadcast,
|
||||
PresenceChannelConfig,
|
||||
} from './types';
|
||||
import { PresenceManager, createPresenceManager } from './manager';
|
||||
import type { TrustLevel } from '../privacy/types';
|
||||
|
||||
// =============================================================================
|
||||
// Hook Configuration
|
||||
// =============================================================================
|
||||
|
||||
export interface UseLocationPresenceConfig {
|
||||
/** Channel/room ID */
|
||||
channelId: string;
|
||||
|
||||
/** User identity */
|
||||
user: {
|
||||
pubKey: string;
|
||||
privKey: string;
|
||||
displayName: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
/** Broadcast function (from Automerge adapter or WebSocket) */
|
||||
broadcastFn?: (data: any) => void;
|
||||
|
||||
/** Whether to start location watch automatically */
|
||||
autoStartLocation?: boolean;
|
||||
|
||||
/** Additional config options */
|
||||
config?: Partial<Omit<PresenceChannelConfig, 'channelId' | 'userPubKey' | 'userPrivKey' | 'displayName' | 'color'>>;
|
||||
}
|
||||
|
||||
export interface UseLocationPresenceReturn {
|
||||
/** Current connection state */
|
||||
connectionState: 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
|
||||
|
||||
/** Own presence data */
|
||||
self: UserPresence;
|
||||
|
||||
/** Views of other users (with trust-based precision) */
|
||||
views: PresenceView[];
|
||||
|
||||
/** Online user count */
|
||||
onlineCount: number;
|
||||
|
||||
/** Start sharing location */
|
||||
startSharing: () => void;
|
||||
|
||||
/** Stop sharing location */
|
||||
stopSharing: () => void;
|
||||
|
||||
/** Whether currently sharing location */
|
||||
isSharing: boolean;
|
||||
|
||||
/** Set manual location */
|
||||
setLocation: (lat: number, lng: number) => Promise<void>;
|
||||
|
||||
/** Clear location */
|
||||
clearLocation: () => void;
|
||||
|
||||
/** Set status */
|
||||
setStatus: (status: PresenceStatus, message?: string) => void;
|
||||
|
||||
/** Set trust level for a user */
|
||||
setTrustLevel: (pubKey: string, level: TrustLevel) => void;
|
||||
|
||||
/** Get trust level for a user */
|
||||
getTrustLevel: (pubKey: string) => TrustLevel;
|
||||
|
||||
/** Handle incoming broadcast (call this with data from network) */
|
||||
handleBroadcast: (broadcast: PresenceBroadcast) => void;
|
||||
|
||||
/** Get users nearby */
|
||||
getNearbyUsers: (maxDistance?: 'here' | 'nearby' | 'same-area' | 'same-city') => PresenceView[];
|
||||
|
||||
/** Presence manager instance (for advanced use) */
|
||||
manager: PresenceManager | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook Implementation
|
||||
// =============================================================================
|
||||
|
||||
export function useLocationPresence(
|
||||
config: UseLocationPresenceConfig
|
||||
): UseLocationPresenceReturn {
|
||||
const { channelId, user, broadcastFn, autoStartLocation = false } = config;
|
||||
|
||||
// State
|
||||
const [connectionState, setConnectionState] = useState<UseLocationPresenceReturn['connectionState']>('connecting');
|
||||
const [self, setSelf] = useState<UserPresence | null>(null);
|
||||
const [views, setViews] = useState<PresenceView[]>([]);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
// Refs
|
||||
const managerRef = useRef<PresenceManager | null>(null);
|
||||
const broadcastFnRef = useRef(broadcastFn);
|
||||
|
||||
// Keep broadcast function ref updated
|
||||
useEffect(() => {
|
||||
broadcastFnRef.current = broadcastFn;
|
||||
}, [broadcastFn]);
|
||||
|
||||
// Initialize manager
|
||||
useEffect(() => {
|
||||
const manager = createPresenceManager({
|
||||
channelId,
|
||||
userPubKey: user.pubKey,
|
||||
userPrivKey: user.privKey,
|
||||
displayName: user.displayName,
|
||||
color: user.color,
|
||||
...config.config,
|
||||
});
|
||||
|
||||
managerRef.current = manager;
|
||||
|
||||
// Subscribe to events
|
||||
const unsubscribe = manager.on((event: PresenceEvent) => {
|
||||
switch (event.type) {
|
||||
case 'connection:changed':
|
||||
setConnectionState(event.state);
|
||||
break;
|
||||
|
||||
case 'user:joined':
|
||||
case 'user:left':
|
||||
case 'user:updated':
|
||||
case 'location:updated':
|
||||
case 'status:changed':
|
||||
// Update views
|
||||
setViews(manager.getViews());
|
||||
break;
|
||||
|
||||
case 'proximity:detected':
|
||||
// Could trigger notifications here
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('Presence error:', event.error);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Start manager with broadcast callback
|
||||
manager.start((broadcast) => {
|
||||
if (broadcastFnRef.current) {
|
||||
broadcastFnRef.current({
|
||||
type: 'location-presence',
|
||||
payload: broadcast,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial self
|
||||
setSelf(manager.getSelf());
|
||||
|
||||
// Auto-start location if configured
|
||||
if (autoStartLocation) {
|
||||
manager.startLocationWatch();
|
||||
setIsSharing(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
manager.stop();
|
||||
managerRef.current = null;
|
||||
};
|
||||
}, [channelId, user.pubKey, user.privKey, user.displayName, user.color, autoStartLocation]);
|
||||
|
||||
// Update self periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (managerRef.current) {
|
||||
setSelf(managerRef.current.getSelf());
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Actions
|
||||
const startSharing = useCallback(() => {
|
||||
if (managerRef.current) {
|
||||
managerRef.current.startLocationWatch();
|
||||
setIsSharing(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopSharing = useCallback(() => {
|
||||
if (managerRef.current) {
|
||||
managerRef.current.stopLocationWatch();
|
||||
managerRef.current.clearLocation();
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLocation = useCallback(async (lat: number, lng: number) => {
|
||||
if (managerRef.current) {
|
||||
await managerRef.current.setLocation(lat, lng, 'manual');
|
||||
setSelf(managerRef.current.getSelf());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearLocation = useCallback(() => {
|
||||
if (managerRef.current) {
|
||||
managerRef.current.clearLocation();
|
||||
setSelf(managerRef.current.getSelf());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setStatus = useCallback((status: PresenceStatus, message?: string) => {
|
||||
if (managerRef.current) {
|
||||
managerRef.current.setStatus(status, message);
|
||||
setSelf(managerRef.current.getSelf());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTrustLevel = useCallback((pubKey: string, level: TrustLevel) => {
|
||||
if (managerRef.current) {
|
||||
managerRef.current.setTrustLevel(pubKey, level);
|
||||
setViews(managerRef.current.getViews());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTrustLevel = useCallback((pubKey: string): TrustLevel => {
|
||||
if (managerRef.current) {
|
||||
return managerRef.current.getTrustLevel(pubKey);
|
||||
}
|
||||
return 'public';
|
||||
}, []);
|
||||
|
||||
const handleBroadcast = useCallback((broadcast: PresenceBroadcast) => {
|
||||
if (managerRef.current) {
|
||||
managerRef.current.handleBroadcast(broadcast);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getNearbyUsers = useCallback((maxDistance: 'here' | 'nearby' | 'same-area' | 'same-city' = 'same-area') => {
|
||||
if (managerRef.current) {
|
||||
return managerRef.current.getUsersNearby(maxDistance);
|
||||
}
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
// Computed values
|
||||
const onlineCount = useMemo(() => {
|
||||
return views.filter((v) => v.status === 'online' || v.status === 'away').length;
|
||||
}, [views]);
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
self: self ?? {
|
||||
pubKey: user.pubKey,
|
||||
displayName: user.displayName,
|
||||
color: user.color,
|
||||
location: null,
|
||||
status: 'online',
|
||||
lastSeen: new Date(),
|
||||
isMoving: false,
|
||||
deviceType: 'unknown',
|
||||
},
|
||||
views,
|
||||
onlineCount,
|
||||
startSharing,
|
||||
stopSharing,
|
||||
isSharing,
|
||||
setLocation,
|
||||
clearLocation,
|
||||
setStatus,
|
||||
setTrustLevel,
|
||||
getTrustLevel,
|
||||
handleBroadcast,
|
||||
getNearbyUsers,
|
||||
manager: managerRef.current,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Presence Indicator Component Data
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get data for rendering a presence indicator on the map
|
||||
*/
|
||||
export interface PresenceIndicatorData {
|
||||
id: string;
|
||||
displayName: string;
|
||||
color: string;
|
||||
position: { lat: number; lng: number };
|
||||
uncertaintyRadius: number;
|
||||
isMoving: boolean;
|
||||
heading?: number;
|
||||
status: PresenceStatus;
|
||||
trustLevel: TrustLevel;
|
||||
isVerified: boolean;
|
||||
lastSeen: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert presence views to indicator data for map rendering
|
||||
*/
|
||||
export function viewsToIndicators(views: PresenceView[]): PresenceIndicatorData[] {
|
||||
return views
|
||||
.filter((v) => v.location !== null)
|
||||
.map((v) => ({
|
||||
id: v.user.pubKey,
|
||||
displayName: v.user.displayName,
|
||||
color: v.user.color,
|
||||
position: {
|
||||
lat: v.location!.center.latitude,
|
||||
lng: v.location!.center.longitude,
|
||||
},
|
||||
uncertaintyRadius: v.location!.uncertaintyRadius,
|
||||
isMoving: v.location!.isMoving,
|
||||
heading: v.location!.heading,
|
||||
status: v.status,
|
||||
trustLevel: v.trustLevel,
|
||||
isVerified: v.isVerified,
|
||||
lastSeen: v.lastSeen,
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
# zkGPS Protocol Specification
|
||||
|
||||
## Overview
|
||||
|
||||
zkGPS is a privacy-preserving location sharing protocol that enables users to prove location claims without revealing exact coordinates. It combines geohash-based commitments with zero-knowledge proofs to enable:
|
||||
|
||||
- **Proximity proofs**: "I am within X meters of location Y"
|
||||
- **Region membership**: "I am inside region R"
|
||||
- **Temporal proofs**: "I was at location L between times T1 and T2"
|
||||
- **Group rendezvous**: "N people are all within X meters of each other"
|
||||
|
||||
## Design Goals
|
||||
|
||||
1. **Privacy by default**: No location data leaves the device unencrypted
|
||||
2. **Configurable precision**: Users control granularity per trust circle
|
||||
3. **Verifiable claims**: Recipients can verify proofs without learning coordinates
|
||||
4. **Efficient**: Proofs must be fast enough for real-time use (<100ms)
|
||||
5. **Offline-capable**: Core operations work without network
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Geohash Commitments
|
||||
|
||||
We use geohash encoding to create hierarchical location commitments:
|
||||
|
||||
```
|
||||
Precision Levels:
|
||||
┌─────────┬────────────────┬─────────────────────┐
|
||||
│ Level │ Cell Size │ Use Case │
|
||||
├─────────┼────────────────┼─────────────────────┤
|
||||
│ 1 │ ~5000 km │ Continent │
|
||||
│ 2 │ ~1250 km │ Large country │
|
||||
│ 3 │ ~156 km │ State/region │
|
||||
│ 4 │ ~39 km │ Metro area │
|
||||
│ 5 │ ~5 km │ City district │
|
||||
│ 6 │ ~1.2 km │ Neighborhood │
|
||||
│ 7 │ ~153 m │ Block │
|
||||
│ 8 │ ~38 m │ Building │
|
||||
│ 9 │ ~5 m │ Room │
|
||||
│ 10 │ ~1.2 m │ Exact position │
|
||||
└─────────┴────────────────┴─────────────────────┘
|
||||
```
|
||||
|
||||
A commitment at level N reveals only the geohash prefix of length N, hiding all finer detail.
|
||||
|
||||
### Commitment Scheme
|
||||
|
||||
```
|
||||
Commit(lat, lng, precision, salt) → C
|
||||
|
||||
Where:
|
||||
geohash = encode(lat, lng, precision)
|
||||
C = Hash(geohash || salt)
|
||||
```
|
||||
|
||||
The salt prevents rainbow table attacks. The precision parameter controls how much location is revealed.
|
||||
|
||||
### Trust Circles
|
||||
|
||||
Users define trust circles with associated precision levels:
|
||||
|
||||
```typescript
|
||||
interface TrustCircle {
|
||||
id: string;
|
||||
name: string;
|
||||
members: string[]; // User IDs or public keys
|
||||
precision: GeohashPrecision; // 1-10
|
||||
updateInterval: number; // How often to broadcast (ms)
|
||||
requireMutual: boolean; // Both parties must be in each other's circle
|
||||
}
|
||||
|
||||
// Example configuration
|
||||
const trustCircles = [
|
||||
{ name: 'Partner', precision: 10, members: ['alice'] }, // ~1m
|
||||
{ name: 'Family', precision: 8, members: ['mom', 'dad'] }, // ~38m
|
||||
{ name: 'Friends', precision: 6, members: [...] }, // ~1.2km
|
||||
{ name: 'Network', precision: 4, members: ['*'] }, // ~39km
|
||||
];
|
||||
```
|
||||
|
||||
## Proof Types
|
||||
|
||||
### 1. Proximity Proof
|
||||
|
||||
Prove: "I am within distance D of point P"
|
||||
|
||||
```
|
||||
ProveProximity(myLocation, targetPoint, maxDistance, salt) → Proof
|
||||
|
||||
Verifier learns: Boolean (within distance or not)
|
||||
Verifier does NOT learn: Exact location, direction, actual distance
|
||||
```
|
||||
|
||||
**Protocol**:
|
||||
1. Prover computes geohash cells that intersect the circle of radius D around P
|
||||
2. Prover commits to their geohash at appropriate precision
|
||||
3. Prover generates ZK proof that their commitment falls within valid cells
|
||||
4. Verifier checks proof without learning which specific cell
|
||||
|
||||
### 2. Region Membership Proof
|
||||
|
||||
Prove: "I am inside polygon R"
|
||||
|
||||
```
|
||||
ProveRegionMembership(myLocation, regionPolygon, salt) → Proof
|
||||
|
||||
Verifier learns: Boolean (inside region or not)
|
||||
Verifier does NOT learn: Where inside the region
|
||||
```
|
||||
|
||||
**Protocol**:
|
||||
1. Region is decomposed into geohash cells at chosen precision
|
||||
2. Prover commits to their location
|
||||
3. Prover generates ZK proof that commitment matches one of the region's cells
|
||||
4. Verifier checks proof
|
||||
|
||||
### 3. Temporal Location Proof
|
||||
|
||||
Prove: "I was at location L between T1 and T2"
|
||||
|
||||
```
|
||||
ProveTemporalPresence(locationHistory, targetRegion, timeRange, salt) → Proof
|
||||
|
||||
Verifier learns: Boolean (was present during time range)
|
||||
Verifier does NOT learn: Exact times, trajectory, duration
|
||||
```
|
||||
|
||||
**Protocol**:
|
||||
1. Prover maintains signed, timestamped location commitments
|
||||
2. Prover selects commitments within time range
|
||||
3. Prover generates proof that at least one commitment falls within region
|
||||
4. Verifier checks signature validity and proof
|
||||
|
||||
### 4. Group Proximity Proof (N-party)
|
||||
|
||||
Prove: "All N participants are within distance D of each other"
|
||||
|
||||
```
|
||||
ProveGroupProximity(participants[], maxDistance) → Proof
|
||||
|
||||
All participants learn: Boolean (group is proximate)
|
||||
No participant learns: Any other participant's location
|
||||
```
|
||||
|
||||
**Protocol** (simplified):
|
||||
1. Each participant commits to their geohash
|
||||
2. Commitments are submitted to a coordinator (or MPC)
|
||||
3. ZK proof computed that all commitments fall within compatible cells
|
||||
4. Result broadcast to all participants
|
||||
|
||||
## Cryptographic Primitives
|
||||
|
||||
### Hash Function
|
||||
- **Primary**: SHA-256 (widely available, fast)
|
||||
- **Alternative**: Poseidon (ZK-friendly, for SNARKs)
|
||||
|
||||
### Commitment Scheme
|
||||
- **Pedersen commitments** for hiding + binding properties
|
||||
- `C = g^m * h^r` where m = geohash numeric encoding, r = randomness
|
||||
|
||||
### Zero-Knowledge Proofs
|
||||
|
||||
For MVP, we use a simplified approach that doesn't require heavy ZK machinery:
|
||||
|
||||
**Geohash Prefix Reveal**:
|
||||
- Reveal only the N-character prefix of geohash
|
||||
- Verifier confirms prefix matches claimed region
|
||||
- No ZK circuit required, just truncation
|
||||
|
||||
For stronger privacy (future):
|
||||
- **Bulletproofs**: Range proofs for coordinate bounds
|
||||
- **Groth16/PLONK**: General circuits for complex predicates
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
### Location Broadcast Message
|
||||
|
||||
```typescript
|
||||
interface LocationBroadcast {
|
||||
// Header
|
||||
version: 1;
|
||||
type: 'location_broadcast';
|
||||
timestamp: number;
|
||||
|
||||
// Sender
|
||||
senderId: string;
|
||||
senderPublicKey: string;
|
||||
|
||||
// Location commitment (encrypted per trust circle)
|
||||
commitments: {
|
||||
trustCircleId: string;
|
||||
encryptedCommitment: string; // Encrypted with circle's shared key
|
||||
precision: number;
|
||||
}[];
|
||||
|
||||
// Signature over entire message
|
||||
signature: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Proximity Query
|
||||
|
||||
```typescript
|
||||
interface ProximityQuery {
|
||||
version: 1;
|
||||
type: 'proximity_query';
|
||||
|
||||
queryId: string;
|
||||
queryer: string;
|
||||
|
||||
// What we're asking
|
||||
targetUserId: string;
|
||||
maxDistance: number; // meters
|
||||
|
||||
// Our commitment (so target can also verify us)
|
||||
ourCommitment: string;
|
||||
ourPrecision: number;
|
||||
}
|
||||
|
||||
interface ProximityResponse {
|
||||
version: 1;
|
||||
type: 'proximity_response';
|
||||
|
||||
queryId: string;
|
||||
responder: string;
|
||||
|
||||
// Result
|
||||
isProximate: boolean;
|
||||
proof?: string; // Optional ZK proof
|
||||
|
||||
signature: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Threat Model
|
||||
|
||||
**Adversary capabilities**:
|
||||
- Can observe all network traffic
|
||||
- Can compromise some participants
|
||||
- Cannot break cryptographic primitives
|
||||
|
||||
**Protected against**:
|
||||
- Passive eavesdropping (all data encrypted)
|
||||
- Location tracking over time (salts rotate)
|
||||
- Correlation attacks (precision limits information)
|
||||
|
||||
**NOT protected against** (by design):
|
||||
- Compromised trusted contacts (they receive your chosen precision)
|
||||
- Physical surveillance
|
||||
- Device compromise
|
||||
|
||||
### Precision Attacks
|
||||
|
||||
An adversary with multiple queries could triangulate location:
|
||||
- **Mitigation**: Rate limiting on queries
|
||||
- **Mitigation**: Minimum precision floor per trust level
|
||||
- **Mitigation**: Query logging for user review
|
||||
|
||||
### Replay Attacks
|
||||
|
||||
Old location proofs could be replayed:
|
||||
- **Mitigation**: Timestamps in commitments
|
||||
- **Mitigation**: Nonces in queries
|
||||
- **Mitigation**: Short expiration windows
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Geohash Commitments (MVP)
|
||||
- Implement geohash encoding/decoding
|
||||
- Simple commitment scheme (Hash + salt)
|
||||
- Trust circle configuration
|
||||
- Precision-based sharing
|
||||
|
||||
### Phase 2: Proximity Proofs
|
||||
- Cell intersection calculation
|
||||
- Simple "prefix match" proofs
|
||||
- Query/response protocol
|
||||
|
||||
### Phase 3: Advanced Proofs
|
||||
- Region membership with polygon decomposition
|
||||
- Temporal proofs with signed history
|
||||
- Integration with canvas presence
|
||||
|
||||
### Phase 4: Group Protocols
|
||||
- N-party proximity (requires coordinator or MPC)
|
||||
- Anonymous presence in regions
|
||||
- Aggregate statistics without individual data
|
||||
|
||||
## References
|
||||
|
||||
- [Geohash specification](https://en.wikipedia.org/wiki/Geohash)
|
||||
- [Pedersen commitments](https://crypto.stackexchange.com/questions/64437/what-is-a-pedersen-commitment)
|
||||
- [Bulletproofs paper](https://eprint.iacr.org/2017/1066.pdf)
|
||||
- [Private proximity testing](https://eprint.iacr.org/2019/961.pdf)
|
||||
|
|
@ -0,0 +1,449 @@
|
|||
/**
|
||||
* Location Commitment Scheme for zkGPS
|
||||
*
|
||||
* Implements hash-based commitments for privacy-preserving location sharing.
|
||||
* A commitment hides the exact location while allowing verification of
|
||||
* location claims at configurable precision levels.
|
||||
*/
|
||||
|
||||
import { encode as geohashEncode } from './geohash';
|
||||
import type {
|
||||
Coordinate,
|
||||
LocationCommitment,
|
||||
CommitmentParams,
|
||||
SignedCommitment,
|
||||
GeohashPrecision,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Cryptographic Utilities
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure random salt
|
||||
*/
|
||||
export function generateSalt(length: number = 32): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// Fallback for environments without crypto API
|
||||
let salt = '';
|
||||
for (let i = 0; i < length * 2; i++) {
|
||||
salt += Math.floor(Math.random() * 16).toString(16);
|
||||
}
|
||||
return salt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of input string
|
||||
*/
|
||||
export async function sha256(message: string): Promise<string> {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const msgBuffer = new TextEncoder().encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// For environments without SubtleCrypto, use a simple hash
|
||||
// This is NOT cryptographically secure and should only be used for testing
|
||||
console.warn('SubtleCrypto not available, using insecure hash');
|
||||
return simpleHash(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash function for testing (NOT cryptographically secure)
|
||||
*/
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(8, '0').repeat(8);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Commitment Creation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a location commitment
|
||||
*
|
||||
* The commitment hides the exact location while revealing only the
|
||||
* geohash prefix at the specified precision level.
|
||||
*
|
||||
* @param params Commitment parameters
|
||||
* @returns Location commitment
|
||||
*/
|
||||
export async function createCommitment(
|
||||
params: CommitmentParams
|
||||
): Promise<LocationCommitment> {
|
||||
const { coordinate, precision, salt, expirationMs = 300000 } = params;
|
||||
|
||||
// Encode location to geohash at full precision (for commitment)
|
||||
const fullGeohash = geohashEncode(coordinate.lat, coordinate.lng, 12);
|
||||
|
||||
// Create commitment: Hash(geohash || salt)
|
||||
const commitmentInput = `${fullGeohash}|${salt}`;
|
||||
const commitment = await sha256(commitmentInput);
|
||||
|
||||
// Calculate revealed prefix at requested precision
|
||||
const revealedPrefix = fullGeohash.slice(0, precision);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
commitment,
|
||||
precision: precision as GeohashPrecision,
|
||||
timestamp: now,
|
||||
expiresAt: now + expirationMs,
|
||||
revealedPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a location commitment
|
||||
*
|
||||
* Given the original location and salt, verify that a commitment is valid.
|
||||
*
|
||||
* @param commitment The commitment to verify
|
||||
* @param coordinate The claimed location
|
||||
* @param salt The salt used when creating the commitment
|
||||
* @returns true if the commitment is valid
|
||||
*/
|
||||
export async function verifyCommitment(
|
||||
commitment: LocationCommitment,
|
||||
coordinate: Coordinate,
|
||||
salt: string
|
||||
): Promise<boolean> {
|
||||
// Check if commitment has expired
|
||||
if (Date.now() > commitment.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recompute the commitment
|
||||
const fullGeohash = geohashEncode(coordinate.lat, coordinate.lng, 12);
|
||||
const commitmentInput = `${fullGeohash}|${salt}`;
|
||||
const recomputedCommitment = await sha256(commitmentInput);
|
||||
|
||||
// Verify commitment matches
|
||||
if (recomputedCommitment !== commitment.commitment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify revealed prefix is consistent
|
||||
const expectedPrefix = fullGeohash.slice(0, commitment.precision);
|
||||
if (commitment.revealedPrefix && commitment.revealedPrefix !== expectedPrefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a commitment matches a claimed geohash prefix
|
||||
*
|
||||
* This allows verifying that someone is in a particular area without
|
||||
* knowing their exact location.
|
||||
*
|
||||
* @param commitment The commitment
|
||||
* @param claimedPrefix The geohash prefix they claim to be in
|
||||
* @returns true if the revealed prefix matches
|
||||
*/
|
||||
export function commitmentMatchesPrefix(
|
||||
commitment: LocationCommitment,
|
||||
claimedPrefix: string
|
||||
): boolean {
|
||||
if (!commitment.revealedPrefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if either prefix is a prefix of the other
|
||||
const shorter = Math.min(commitment.revealedPrefix.length, claimedPrefix.length);
|
||||
return (
|
||||
commitment.revealedPrefix.slice(0, shorter) === claimedPrefix.slice(0, shorter)
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Commitment Signing
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Sign a commitment with a private key
|
||||
*
|
||||
* Creates a signed commitment that can be verified by others.
|
||||
* Uses Ed25519 or ECDSA depending on availability.
|
||||
*
|
||||
* @param commitment The commitment to sign
|
||||
* @param privateKey The signer's private key (hex)
|
||||
* @param publicKey The signer's public key (hex)
|
||||
* @returns Signed commitment
|
||||
*/
|
||||
export async function signCommitment(
|
||||
commitment: LocationCommitment,
|
||||
privateKey: string,
|
||||
publicKey: string
|
||||
): Promise<SignedCommitment> {
|
||||
// Create message to sign: commitment hash + timestamp
|
||||
const message = `${commitment.commitment}|${commitment.timestamp}|${commitment.expiresAt}`;
|
||||
|
||||
// Sign the message
|
||||
const signature = await signMessage(message, privateKey);
|
||||
|
||||
return {
|
||||
...commitment,
|
||||
signature,
|
||||
signerPublicKey: publicKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed commitment
|
||||
*
|
||||
* @param signedCommitment The signed commitment to verify
|
||||
* @returns true if the signature is valid
|
||||
*/
|
||||
export async function verifySignedCommitment(
|
||||
signedCommitment: SignedCommitment
|
||||
): Promise<boolean> {
|
||||
// Check expiration
|
||||
if (Date.now() > signedCommitment.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recreate the signed message
|
||||
const message = `${signedCommitment.commitment}|${signedCommitment.timestamp}|${signedCommitment.expiresAt}`;
|
||||
|
||||
// Verify signature
|
||||
return verifySignature(
|
||||
message,
|
||||
signedCommitment.signature,
|
||||
signedCommitment.signerPublicKey
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Key Generation and Signing Primitives
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generate a new key pair for signing commitments
|
||||
*
|
||||
* @returns Object with publicKey and privateKey (hex encoded)
|
||||
*/
|
||||
export async function generateKeyPair(): Promise<{
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}> {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
try {
|
||||
// Try to use ECDSA with P-256
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify']
|
||||
);
|
||||
|
||||
// Export keys
|
||||
const publicKeyBuffer = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
const privateKeyBuffer = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
|
||||
return {
|
||||
publicKey: bufferToHex(publicKeyBuffer),
|
||||
privateKey: bufferToHex(privateKeyBuffer),
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('ECDSA key generation failed, using fallback', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: generate random bytes as "keys" (NOT secure, testing only)
|
||||
console.warn('Using insecure key generation fallback');
|
||||
return {
|
||||
publicKey: generateSalt(32),
|
||||
privateKey: generateSalt(64),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a message with a private key
|
||||
*/
|
||||
async function signMessage(message: string, privateKey: string): Promise<string> {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
try {
|
||||
// Import the private key
|
||||
const keyBuffer = hexToBuffer(privateKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBuffer,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
// Sign the message
|
||||
const messageBuffer = new TextEncoder().encode(message);
|
||||
const signatureBuffer = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
cryptoKey,
|
||||
messageBuffer
|
||||
);
|
||||
|
||||
return bufferToHex(signatureBuffer);
|
||||
} catch (e) {
|
||||
console.warn('ECDSA signing failed, using fallback', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: HMAC-like construction (NOT secure, testing only)
|
||||
return sha256(`${message}|${privateKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signature
|
||||
*/
|
||||
async function verifySignature(
|
||||
message: string,
|
||||
signature: string,
|
||||
publicKey: string
|
||||
): Promise<boolean> {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
try {
|
||||
// Import the public key
|
||||
const keyBuffer = hexToBuffer(publicKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBuffer,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
|
||||
// Verify the signature
|
||||
const messageBuffer = new TextEncoder().encode(message);
|
||||
const signatureBuffer = hexToBuffer(signature);
|
||||
|
||||
return crypto.subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
cryptoKey,
|
||||
signatureBuffer,
|
||||
messageBuffer
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('ECDSA verification failed, using fallback', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: recompute and compare (NOT secure, testing only)
|
||||
const expected = await sha256(`${message}|${publicKey}`);
|
||||
return signature === expected;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buffer), (b) =>
|
||||
b.toString(16).padStart(2, '0')
|
||||
).join('');
|
||||
}
|
||||
|
||||
function hexToBuffer(hex: string): ArrayBuffer {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Commitment Store (for managing multiple commitments)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* In-memory commitment store for managing location commitments
|
||||
*/
|
||||
export class CommitmentStore {
|
||||
private commitments: Map<string, LocationCommitment> = new Map();
|
||||
private salts: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Create and store a new commitment
|
||||
*/
|
||||
async createAndStore(
|
||||
coordinate: Coordinate,
|
||||
precision: GeohashPrecision,
|
||||
expirationMs?: number
|
||||
): Promise<{ commitment: LocationCommitment; salt: string }> {
|
||||
const salt = generateSalt();
|
||||
const commitment = await createCommitment({
|
||||
coordinate,
|
||||
precision,
|
||||
salt,
|
||||
expirationMs,
|
||||
});
|
||||
|
||||
this.commitments.set(commitment.commitment, commitment);
|
||||
this.salts.set(commitment.commitment, salt);
|
||||
|
||||
return { commitment, salt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a commitment by its hash
|
||||
*/
|
||||
get(commitmentHash: string): LocationCommitment | undefined {
|
||||
return this.commitments.get(commitmentHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the salt for a commitment (only available to creator)
|
||||
*/
|
||||
getSalt(commitmentHash: string): string | undefined {
|
||||
return this.salts.get(commitmentHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expired commitments
|
||||
*/
|
||||
pruneExpired(): number {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
|
||||
for (const [hash, commitment] of this.commitments) {
|
||||
if (commitment.expiresAt < now) {
|
||||
this.commitments.delete(hash);
|
||||
this.salts.delete(hash);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active (non-expired) commitments
|
||||
*/
|
||||
getActive(): LocationCommitment[] {
|
||||
const now = Date.now();
|
||||
return Array.from(this.commitments.values()).filter(
|
||||
(c) => c.expiresAt >= now
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all commitments
|
||||
*/
|
||||
clear(): void {
|
||||
this.commitments.clear();
|
||||
this.salts.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
/**
|
||||
* Geohash encoding/decoding utilities for zkGPS
|
||||
*
|
||||
* Geohash is a hierarchical spatial encoding that converts lat/lng to a string.
|
||||
* Each character adds precision, enabling variable-granularity location sharing.
|
||||
*
|
||||
* Precision table:
|
||||
* 1 char = ~5000 km (continent)
|
||||
* 4 chars = ~39 km (metro)
|
||||
* 6 chars = ~1.2 km (neighborhood)
|
||||
* 8 chars = ~38 m (building)
|
||||
* 10 chars = ~1.2 m (exact)
|
||||
*/
|
||||
|
||||
// Base32 alphabet used by geohash (excludes a, i, l, o to avoid confusion)
|
||||
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';
|
||||
const BASE32_MAP = new Map(BASE32.split('').map((c, i) => [c, i]));
|
||||
|
||||
/**
|
||||
* Geohash precision levels with approximate cell sizes
|
||||
*/
|
||||
export const GEOHASH_PRECISION = {
|
||||
CONTINENT: 1, // ~5000 km
|
||||
LARGE_COUNTRY: 2, // ~1250 km
|
||||
STATE: 3, // ~156 km
|
||||
METRO: 4, // ~39 km
|
||||
DISTRICT: 5, // ~5 km
|
||||
NEIGHBORHOOD: 6, // ~1.2 km
|
||||
BLOCK: 7, // ~153 m
|
||||
BUILDING: 8, // ~38 m
|
||||
ROOM: 9, // ~5 m
|
||||
EXACT: 10, // ~1.2 m
|
||||
} as const;
|
||||
|
||||
export type GeohashPrecision = typeof GEOHASH_PRECISION[keyof typeof GEOHASH_PRECISION];
|
||||
|
||||
/**
|
||||
* Approximate cell dimensions at each precision level (meters)
|
||||
*/
|
||||
export const PRECISION_CELL_SIZE: Record<number, { lat: number; lng: number }> = {
|
||||
1: { lat: 5000000, lng: 5000000 },
|
||||
2: { lat: 1250000, lng: 625000 },
|
||||
3: { lat: 156000, lng: 156000 },
|
||||
4: { lat: 39000, lng: 19500 },
|
||||
5: { lat: 4900, lng: 4900 },
|
||||
6: { lat: 1200, lng: 610 },
|
||||
7: { lat: 153, lng: 153 },
|
||||
8: { lat: 38, lng: 19 },
|
||||
9: { lat: 4.8, lng: 4.8 },
|
||||
10: { lat: 1.2, lng: 0.6 },
|
||||
11: { lat: 0.15, lng: 0.15 },
|
||||
12: { lat: 0.037, lng: 0.019 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounding box for a geohash cell
|
||||
*/
|
||||
export interface GeohashBounds {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode latitude/longitude to geohash string
|
||||
*
|
||||
* @param lat Latitude (-90 to 90)
|
||||
* @param lng Longitude (-180 to 180)
|
||||
* @param precision Number of characters (1-12)
|
||||
* @returns Geohash string
|
||||
*/
|
||||
export function encode(lat: number, lng: number, precision: number = 9): string {
|
||||
if (precision < 1 || precision > 12) {
|
||||
throw new Error('Precision must be between 1 and 12');
|
||||
}
|
||||
if (lat < -90 || lat > 90) {
|
||||
throw new Error('Latitude must be between -90 and 90');
|
||||
}
|
||||
if (lng < -180 || lng > 180) {
|
||||
throw new Error('Longitude must be between -180 and 180');
|
||||
}
|
||||
|
||||
let minLat = -90, maxLat = 90;
|
||||
let minLng = -180, maxLng = 180;
|
||||
let hash = '';
|
||||
let bit = 0;
|
||||
let ch = 0;
|
||||
let isLng = true; // Alternate between lng and lat
|
||||
|
||||
while (hash.length < precision) {
|
||||
if (isLng) {
|
||||
const mid = (minLng + maxLng) / 2;
|
||||
if (lng >= mid) {
|
||||
ch |= 1 << (4 - bit);
|
||||
minLng = mid;
|
||||
} else {
|
||||
maxLng = mid;
|
||||
}
|
||||
} else {
|
||||
const mid = (minLat + maxLat) / 2;
|
||||
if (lat >= mid) {
|
||||
ch |= 1 << (4 - bit);
|
||||
minLat = mid;
|
||||
} else {
|
||||
maxLat = mid;
|
||||
}
|
||||
}
|
||||
|
||||
isLng = !isLng;
|
||||
bit++;
|
||||
|
||||
if (bit === 5) {
|
||||
hash += BASE32[ch];
|
||||
bit = 0;
|
||||
ch = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode geohash string to latitude/longitude (center of cell)
|
||||
*
|
||||
* @param hash Geohash string
|
||||
* @returns { lat, lng } center point
|
||||
*/
|
||||
export function decode(hash: string): { lat: number; lng: number } {
|
||||
const bounds = decodeBounds(hash);
|
||||
return {
|
||||
lat: (bounds.minLat + bounds.maxLat) / 2,
|
||||
lng: (bounds.minLng + bounds.maxLng) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode geohash string to bounding box
|
||||
*
|
||||
* @param hash Geohash string
|
||||
* @returns Bounding box of the cell
|
||||
*/
|
||||
export function decodeBounds(hash: string): GeohashBounds {
|
||||
let minLat = -90, maxLat = 90;
|
||||
let minLng = -180, maxLng = 180;
|
||||
let isLng = true;
|
||||
|
||||
for (const c of hash.toLowerCase()) {
|
||||
const bits = BASE32_MAP.get(c);
|
||||
if (bits === undefined) {
|
||||
throw new Error(`Invalid geohash character: ${c}`);
|
||||
}
|
||||
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
const bit = (bits >> i) & 1;
|
||||
if (isLng) {
|
||||
const mid = (minLng + maxLng) / 2;
|
||||
if (bit) {
|
||||
minLng = mid;
|
||||
} else {
|
||||
maxLng = mid;
|
||||
}
|
||||
} else {
|
||||
const mid = (minLat + maxLat) / 2;
|
||||
if (bit) {
|
||||
minLat = mid;
|
||||
} else {
|
||||
maxLat = mid;
|
||||
}
|
||||
}
|
||||
isLng = !isLng;
|
||||
}
|
||||
}
|
||||
|
||||
return { minLat, maxLat, minLng, maxLng };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all 8 neighboring geohash cells
|
||||
*
|
||||
* @param hash Geohash string
|
||||
* @returns Array of 8 neighboring geohash strings
|
||||
*/
|
||||
export function neighbors(hash: string): string[] {
|
||||
const { lat, lng } = decode(hash);
|
||||
const bounds = decodeBounds(hash);
|
||||
const latDelta = bounds.maxLat - bounds.minLat;
|
||||
const lngDelta = bounds.maxLng - bounds.minLng;
|
||||
const precision = hash.length;
|
||||
|
||||
const directions = [
|
||||
{ dLat: latDelta, dLng: 0 }, // N
|
||||
{ dLat: latDelta, dLng: lngDelta }, // NE
|
||||
{ dLat: 0, dLng: lngDelta }, // E
|
||||
{ dLat: -latDelta, dLng: lngDelta }, // SE
|
||||
{ dLat: -latDelta, dLng: 0 }, // S
|
||||
{ dLat: -latDelta, dLng: -lngDelta }, // SW
|
||||
{ dLat: 0, dLng: -lngDelta }, // W
|
||||
{ dLat: latDelta, dLng: -lngDelta }, // NW
|
||||
];
|
||||
|
||||
return directions.map(({ dLat, dLng }) => {
|
||||
let newLat = lat + dLat;
|
||||
let newLng = lng + dLng;
|
||||
|
||||
// Wrap longitude
|
||||
if (newLng > 180) newLng -= 360;
|
||||
if (newLng < -180) newLng += 360;
|
||||
|
||||
// Clamp latitude (can't wrap)
|
||||
newLat = Math.max(-90, Math.min(90, newLat));
|
||||
|
||||
return encode(newLat, newLng, precision);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point is inside a geohash cell
|
||||
*
|
||||
* @param lat Latitude
|
||||
* @param lng Longitude
|
||||
* @param hash Geohash string
|
||||
* @returns true if point is inside the cell
|
||||
*/
|
||||
export function contains(lat: number, lng: number, hash: string): boolean {
|
||||
const bounds = decodeBounds(hash);
|
||||
return (
|
||||
lat >= bounds.minLat &&
|
||||
lat <= bounds.maxLat &&
|
||||
lng >= bounds.minLng &&
|
||||
lng <= bounds.maxLng
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all geohash cells that intersect a circle
|
||||
*
|
||||
* @param centerLat Center latitude
|
||||
* @param centerLng Center longitude
|
||||
* @param radiusMeters Radius in meters
|
||||
* @param precision Geohash precision
|
||||
* @returns Array of geohash strings that intersect the circle
|
||||
*/
|
||||
export function cellsInRadius(
|
||||
centerLat: number,
|
||||
centerLng: number,
|
||||
radiusMeters: number,
|
||||
precision: number
|
||||
): string[] {
|
||||
const cells = new Set<string>();
|
||||
const centerHash = encode(centerLat, centerLng, precision);
|
||||
cells.add(centerHash);
|
||||
|
||||
// BFS to find all intersecting cells
|
||||
const queue = [centerHash];
|
||||
const visited = new Set<string>([centerHash]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const neighborList = neighbors(current);
|
||||
|
||||
for (const neighbor of neighborList) {
|
||||
if (visited.has(neighbor)) continue;
|
||||
visited.add(neighbor);
|
||||
|
||||
// Check if this cell intersects the circle
|
||||
if (cellIntersectsCircle(neighbor, centerLat, centerLng, radiusMeters)) {
|
||||
cells.add(neighbor);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(cells);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a geohash cell intersects a circle
|
||||
*/
|
||||
function cellIntersectsCircle(
|
||||
hash: string,
|
||||
centerLat: number,
|
||||
centerLng: number,
|
||||
radiusMeters: number
|
||||
): boolean {
|
||||
const bounds = decodeBounds(hash);
|
||||
|
||||
// Find closest point on cell to circle center
|
||||
const closestLat = Math.max(bounds.minLat, Math.min(bounds.maxLat, centerLat));
|
||||
const closestLng = Math.max(bounds.minLng, Math.min(bounds.maxLng, centerLng));
|
||||
|
||||
// Calculate distance to closest point
|
||||
const distance = haversineDistance(
|
||||
centerLat,
|
||||
centerLng,
|
||||
closestLat,
|
||||
closestLng
|
||||
);
|
||||
|
||||
return distance <= radiusMeters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine distance between two points (meters)
|
||||
*/
|
||||
function haversineDistance(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number
|
||||
): number {
|
||||
const R = 6371000; // Earth radius in meters
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function toRad(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get geohash cells that cover a polygon (approximation)
|
||||
*
|
||||
* @param polygon Array of [lat, lng] points forming a closed polygon
|
||||
* @param precision Geohash precision
|
||||
* @returns Array of geohash strings that intersect the polygon
|
||||
*/
|
||||
export function cellsInPolygon(
|
||||
polygon: [number, number][],
|
||||
precision: number
|
||||
): string[] {
|
||||
// Find bounding box of polygon
|
||||
let minLat = Infinity, maxLat = -Infinity;
|
||||
let minLng = Infinity, maxLng = -Infinity;
|
||||
|
||||
for (const [lat, lng] of polygon) {
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
minLng = Math.min(minLng, lng);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
}
|
||||
|
||||
// Get cell size at this precision
|
||||
const cellSize = PRECISION_CELL_SIZE[precision] || PRECISION_CELL_SIZE[12];
|
||||
const latStep = cellSize.lat / 111000; // meters to degrees (rough)
|
||||
const lngStep = cellSize.lng / (111000 * Math.cos(toRad((minLat + maxLat) / 2)));
|
||||
|
||||
const cells = new Set<string>();
|
||||
|
||||
// Sample points in bounding box
|
||||
for (let lat = minLat; lat <= maxLat; lat += latStep * 0.5) {
|
||||
for (let lng = minLng; lng <= maxLng; lng += lngStep * 0.5) {
|
||||
if (pointInPolygon(lat, lng, polygon)) {
|
||||
cells.add(encode(lat, lng, precision));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(cells);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ray casting algorithm for point-in-polygon test
|
||||
*/
|
||||
function pointInPolygon(lat: number, lng: number, polygon: [number, number][]): boolean {
|
||||
let inside = false;
|
||||
const n = polygon.length;
|
||||
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const [yi, xi] = polygon[i];
|
||||
const [yj, xj] = polygon[j];
|
||||
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate geohash to lower precision (reveal less location info)
|
||||
*
|
||||
* @param hash Full geohash
|
||||
* @param precision Target precision (must be <= current length)
|
||||
* @returns Truncated geohash
|
||||
*/
|
||||
export function truncate(hash: string, precision: number): string {
|
||||
if (precision >= hash.length) return hash;
|
||||
if (precision < 1) return '';
|
||||
return hash.slice(0, precision);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two geohashes share a common prefix (are in same area)
|
||||
*
|
||||
* @param hash1 First geohash
|
||||
* @param hash2 Second geohash
|
||||
* @param minLength Minimum prefix length to match
|
||||
* @returns true if they share a prefix of at least minLength
|
||||
*/
|
||||
export function sharesPrefix(hash1: string, hash2: string, minLength: number): boolean {
|
||||
const prefix1 = truncate(hash1, minLength);
|
||||
const prefix2 = truncate(hash2, minLength);
|
||||
return prefix1 === prefix2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate appropriate precision for a given radius
|
||||
*
|
||||
* @param radiusMeters Desired radius in meters
|
||||
* @returns Recommended geohash precision
|
||||
*/
|
||||
export function precisionForRadius(radiusMeters: number): number {
|
||||
for (let p = 12; p >= 1; p--) {
|
||||
const cellSize = PRECISION_CELL_SIZE[p];
|
||||
if (cellSize && Math.max(cellSize.lat, cellSize.lng) <= radiusMeters * 2) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* zkGPS Privacy Module
|
||||
*
|
||||
* Privacy-preserving location sharing protocol that enables:
|
||||
* - Variable precision location sharing via trust circles
|
||||
* - Proximity proofs without revealing exact location
|
||||
* - Region membership proofs
|
||||
* - Temporal presence proofs
|
||||
* - Group proximity verification
|
||||
*/
|
||||
|
||||
// Core types
|
||||
export * from './types';
|
||||
|
||||
// Geohash encoding/decoding
|
||||
export {
|
||||
encode as encodeGeohash,
|
||||
decode as decodeGeohash,
|
||||
decodeBounds,
|
||||
neighbors,
|
||||
contains,
|
||||
cellsInRadius,
|
||||
cellsInPolygon,
|
||||
truncate,
|
||||
sharesPrefix,
|
||||
precisionForRadius,
|
||||
GEOHASH_PRECISION,
|
||||
PRECISION_CELL_SIZE,
|
||||
type GeohashBounds,
|
||||
type GeohashPrecision,
|
||||
} from './geohash';
|
||||
|
||||
// Commitments
|
||||
export {
|
||||
generateSalt,
|
||||
sha256,
|
||||
createCommitment,
|
||||
verifyCommitment,
|
||||
commitmentMatchesPrefix,
|
||||
signCommitment,
|
||||
verifySignedCommitment,
|
||||
generateKeyPair,
|
||||
CommitmentStore,
|
||||
} from './commitments';
|
||||
|
||||
// Proofs
|
||||
export {
|
||||
generateProximityProof,
|
||||
verifyProximityProof,
|
||||
generateRegionProof,
|
||||
verifyRegionProof,
|
||||
generateGroupProximityProof,
|
||||
generateTemporalProof,
|
||||
areLocationsProximate,
|
||||
isLocationInRegion,
|
||||
getDistance,
|
||||
type GroupParticipant,
|
||||
type HistoryEntry,
|
||||
} from './proofs';
|
||||
|
||||
// Trust circles
|
||||
export {
|
||||
TrustCircleManager,
|
||||
createTrustCircleManager,
|
||||
loadTrustCircleManager,
|
||||
describeTrustLevel,
|
||||
getTrustLevelFromPrecision,
|
||||
validateCircle,
|
||||
} from './trustCircles';
|
||||
|
|
@ -0,0 +1,553 @@
|
|||
/**
|
||||
* Proximity Proofs for zkGPS
|
||||
*
|
||||
* Implements zero-knowledge proofs for location claims:
|
||||
* - Proximity proofs: "I am within X meters of point P"
|
||||
* - Region membership: "I am inside region R"
|
||||
* - Group proximity: "All N participants are within X meters"
|
||||
*
|
||||
* MVP uses geohash cell intersection (simple but effective).
|
||||
* Future versions can use proper ZK circuits (Bulletproofs, Groth16).
|
||||
*/
|
||||
|
||||
import {
|
||||
encode as geohashEncode,
|
||||
cellsInRadius,
|
||||
cellsInPolygon,
|
||||
sharesPrefix,
|
||||
precisionForRadius,
|
||||
PRECISION_CELL_SIZE,
|
||||
} from './geohash';
|
||||
import { createCommitment, sha256, generateSalt } from './commitments';
|
||||
import type {
|
||||
Coordinate,
|
||||
ProximityProof,
|
||||
RegionProof,
|
||||
GroupProximityProof,
|
||||
TemporalProof,
|
||||
LocationCommitment,
|
||||
GeohashPrecision,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Proximity Proofs
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generate a proximity proof
|
||||
*
|
||||
* Proves that the prover is within `maxDistance` meters of `targetPoint`
|
||||
* without revealing their exact location.
|
||||
*
|
||||
* @param myLocation The prover's actual location (kept secret)
|
||||
* @param targetPoint The public target point
|
||||
* @param maxDistance Maximum distance in meters
|
||||
* @param privateKey Prover's private key for signing
|
||||
* @param publicKey Prover's public key
|
||||
* @returns Proximity proof
|
||||
*/
|
||||
export async function generateProximityProof(
|
||||
myLocation: Coordinate,
|
||||
targetPoint: Coordinate,
|
||||
maxDistance: number,
|
||||
privateKey: string,
|
||||
publicKey: string
|
||||
): Promise<ProximityProof> {
|
||||
// Determine appropriate precision for the distance
|
||||
const precision = precisionForRadius(maxDistance);
|
||||
|
||||
// Get all geohash cells that intersect the proximity circle
|
||||
const validCells = cellsInRadius(
|
||||
targetPoint.lat,
|
||||
targetPoint.lng,
|
||||
maxDistance,
|
||||
precision
|
||||
);
|
||||
|
||||
// Get my geohash at this precision
|
||||
const myGeohash = geohashEncode(myLocation.lat, myLocation.lng, precision);
|
||||
|
||||
// Check if I'm in one of the valid cells
|
||||
const isProximate = validCells.includes(myGeohash);
|
||||
|
||||
// Generate proof data
|
||||
// In MVP, we reveal the precision level and the fact that we're in a valid cell
|
||||
// without revealing which specific cell
|
||||
const proofData = {
|
||||
precision,
|
||||
validCellCount: validCells.length,
|
||||
// Commitment to our cell (without revealing which one)
|
||||
cellCommitment: await sha256(`${myGeohash}|${generateSalt(16)}`),
|
||||
// Merkle root of valid cells (for verification)
|
||||
validCellsRoot: await computeMerkleRoot(validCells),
|
||||
};
|
||||
|
||||
const proofId = await sha256(`proximity|${Date.now()}|${generateSalt(8)}`);
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Create signature over the proof
|
||||
const signatureMessage = `${proofId}|${timestamp}|${isProximate}|${targetPoint.lat}|${targetPoint.lng}|${maxDistance}`;
|
||||
const signature = await sha256(`${signatureMessage}|${privateKey}`);
|
||||
|
||||
return {
|
||||
type: 'proximity',
|
||||
proofId,
|
||||
timestamp,
|
||||
proverPublicKey: publicKey,
|
||||
proof: JSON.stringify(proofData),
|
||||
signature,
|
||||
targetPoint,
|
||||
maxDistance,
|
||||
result: isProximate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a proximity proof
|
||||
*
|
||||
* Note: In this MVP, we trust the proof result. A full ZK implementation
|
||||
* would cryptographically verify the proof without trusting the prover.
|
||||
*
|
||||
* @param proof The proximity proof to verify
|
||||
* @returns true if the proof structure is valid
|
||||
*/
|
||||
export async function verifyProximityProof(
|
||||
proof: ProximityProof
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const proofData = JSON.parse(proof.proof);
|
||||
|
||||
// Verify proof has required fields
|
||||
if (!proofData.precision || !proofData.validCellCount || !proofData.validCellsRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify timestamp is recent (within 5 minutes)
|
||||
const maxAge = 5 * 60 * 1000; // 5 minutes
|
||||
if (Date.now() - proof.timestamp > maxAge) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recompute valid cells and verify merkle root
|
||||
const validCells = cellsInRadius(
|
||||
proof.targetPoint.lat,
|
||||
proof.targetPoint.lng,
|
||||
proof.maxDistance,
|
||||
proofData.precision
|
||||
);
|
||||
|
||||
const expectedRoot = await computeMerkleRoot(validCells);
|
||||
if (expectedRoot !== proofData.validCellsRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify cell count matches
|
||||
if (validCells.length !== proofData.validCellCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// In a full ZK implementation, we would verify the cryptographic proof
|
||||
// that the prover's cell commitment is in the valid set
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Region Membership Proofs
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generate a region membership proof
|
||||
*
|
||||
* Proves that the prover is inside a polygon region without revealing
|
||||
* their exact position within the region.
|
||||
*
|
||||
* @param myLocation The prover's actual location
|
||||
* @param regionPolygon Array of [lat, lng] points defining the region
|
||||
* @param regionId Unique identifier for this region
|
||||
* @param regionName Human-readable region name
|
||||
* @param privateKey Prover's private key
|
||||
* @param publicKey Prover's public key
|
||||
* @returns Region membership proof
|
||||
*/
|
||||
export async function generateRegionProof(
|
||||
myLocation: Coordinate,
|
||||
regionPolygon: [number, number][],
|
||||
regionId: string,
|
||||
regionName: string,
|
||||
privateKey: string,
|
||||
publicKey: string
|
||||
): Promise<RegionProof> {
|
||||
// Determine precision based on region size
|
||||
const bounds = getPolygonBounds(regionPolygon);
|
||||
const regionSizeMeters = Math.max(
|
||||
haversineDistance(bounds.minLat, bounds.minLng, bounds.maxLat, bounds.minLng),
|
||||
haversineDistance(bounds.minLat, bounds.minLng, bounds.minLat, bounds.maxLng)
|
||||
);
|
||||
const precision = precisionForRadius(regionSizeMeters / 4);
|
||||
|
||||
// Get all cells in the region
|
||||
const regionCells = cellsInPolygon(regionPolygon, precision);
|
||||
|
||||
// Get my geohash
|
||||
const myGeohash = geohashEncode(myLocation.lat, myLocation.lng, precision);
|
||||
|
||||
// Check if I'm in the region
|
||||
const isInRegion = regionCells.includes(myGeohash);
|
||||
|
||||
// Generate proof data
|
||||
const proofData = {
|
||||
precision,
|
||||
regionCellCount: regionCells.length,
|
||||
regionCellsRoot: await computeMerkleRoot(regionCells),
|
||||
cellCommitment: await sha256(`${myGeohash}|${generateSalt(16)}`),
|
||||
};
|
||||
|
||||
const proofId = await sha256(`region|${regionId}|${Date.now()}`);
|
||||
const timestamp = Date.now();
|
||||
|
||||
const signatureMessage = `${proofId}|${timestamp}|${isInRegion}|${regionId}`;
|
||||
const signature = await sha256(`${signatureMessage}|${privateKey}`);
|
||||
|
||||
return {
|
||||
type: 'region',
|
||||
proofId,
|
||||
timestamp,
|
||||
proverPublicKey: publicKey,
|
||||
proof: JSON.stringify(proofData),
|
||||
signature,
|
||||
regionId,
|
||||
regionName,
|
||||
result: isInRegion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a region membership proof
|
||||
*/
|
||||
export async function verifyRegionProof(
|
||||
proof: RegionProof,
|
||||
regionPolygon: [number, number][]
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const proofData = JSON.parse(proof.proof);
|
||||
|
||||
// Verify timestamp
|
||||
const maxAge = 5 * 60 * 1000;
|
||||
if (Date.now() - proof.timestamp > maxAge) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recompute region cells
|
||||
const regionCells = cellsInPolygon(regionPolygon, proofData.precision);
|
||||
|
||||
// Verify merkle root
|
||||
const expectedRoot = await computeMerkleRoot(regionCells);
|
||||
if (expectedRoot !== proofData.regionCellsRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Group Proximity Proofs
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Participant's commitment for group proximity proof
|
||||
*/
|
||||
export interface GroupParticipant {
|
||||
publicKey: string;
|
||||
commitment: LocationCommitment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a group proximity proof
|
||||
*
|
||||
* Proves that all participants are within `maxDistance` of each other.
|
||||
* This requires coordination between all participants.
|
||||
*
|
||||
* @param participants Array of participant commitments
|
||||
* @param maxDistance Maximum pairwise distance in meters
|
||||
* @param coordinatorPrivateKey Coordinator's private key
|
||||
* @param coordinatorPublicKey Coordinator's public key
|
||||
* @returns Group proximity proof
|
||||
*/
|
||||
export async function generateGroupProximityProof(
|
||||
participants: GroupParticipant[],
|
||||
maxDistance: number,
|
||||
coordinatorPrivateKey: string,
|
||||
coordinatorPublicKey: string
|
||||
): Promise<GroupProximityProof> {
|
||||
const precision = precisionForRadius(maxDistance);
|
||||
|
||||
// Extract revealed prefixes from commitments
|
||||
const prefixes = participants
|
||||
.map((p) => p.commitment.revealedPrefix)
|
||||
.filter((p): p is string => p !== undefined);
|
||||
|
||||
// Check if all participants share a common prefix at appropriate precision
|
||||
// For group proximity, we check if all prefixes are compatible
|
||||
const minPrefixLength = Math.min(...prefixes.map((p) => p.length));
|
||||
const compatiblePrecision = Math.min(precision, minPrefixLength);
|
||||
|
||||
let allProximate = true;
|
||||
for (let i = 0; i < prefixes.length && allProximate; i++) {
|
||||
for (let j = i + 1; j < prefixes.length && allProximate; j++) {
|
||||
if (!sharesPrefix(prefixes[i], prefixes[j], compatiblePrecision)) {
|
||||
allProximate = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate proof
|
||||
const proofId = await sha256(`group|${Date.now()}|${generateSalt(8)}`);
|
||||
const timestamp = Date.now();
|
||||
|
||||
const proofData = {
|
||||
precision: compatiblePrecision,
|
||||
participantCount: participants.length,
|
||||
commitmentsRoot: await computeMerkleRoot(
|
||||
participants.map((p) => p.commitment.commitment)
|
||||
),
|
||||
};
|
||||
|
||||
const signatureMessage = `${proofId}|${timestamp}|${allProximate}|${participants.length}|${maxDistance}`;
|
||||
const signature = await sha256(`${signatureMessage}|${coordinatorPrivateKey}`);
|
||||
|
||||
return {
|
||||
type: 'group',
|
||||
proofId,
|
||||
timestamp,
|
||||
proverPublicKey: coordinatorPublicKey,
|
||||
proof: JSON.stringify(proofData),
|
||||
signature,
|
||||
participants: participants.map((p) => p.publicKey),
|
||||
maxDistance,
|
||||
result: allProximate,
|
||||
// Don't reveal centroid unless explicitly needed
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Temporal Proofs
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Location history entry for temporal proofs
|
||||
*/
|
||||
export interface HistoryEntry {
|
||||
commitment: LocationCommitment;
|
||||
coordinate: Coordinate; // Only stored locally, never shared
|
||||
salt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a temporal presence proof
|
||||
*
|
||||
* Proves that the prover was at a location during a time range.
|
||||
*
|
||||
* @param history Array of signed location commitments with timestamps
|
||||
* @param targetLocation Target location or region
|
||||
* @param timeRange Start and end timestamps
|
||||
* @param privateKey Prover's private key
|
||||
* @param publicKey Prover's public key
|
||||
*/
|
||||
export async function generateTemporalProof(
|
||||
history: HistoryEntry[],
|
||||
targetLocation: Coordinate,
|
||||
timeRange: { start: number; end: number },
|
||||
maxDistance: number,
|
||||
privateKey: string,
|
||||
publicKey: string
|
||||
): Promise<TemporalProof> {
|
||||
// Filter history to time range
|
||||
const relevantHistory = history.filter(
|
||||
(h) =>
|
||||
h.commitment.timestamp >= timeRange.start &&
|
||||
h.commitment.timestamp <= timeRange.end
|
||||
);
|
||||
|
||||
// Check if any entries are within distance of target
|
||||
const precision = precisionForRadius(maxDistance);
|
||||
const validCells = cellsInRadius(
|
||||
targetLocation.lat,
|
||||
targetLocation.lng,
|
||||
maxDistance,
|
||||
precision
|
||||
);
|
||||
|
||||
let wasPresent = false;
|
||||
for (const entry of relevantHistory) {
|
||||
const entryGeohash = geohashEncode(
|
||||
entry.coordinate.lat,
|
||||
entry.coordinate.lng,
|
||||
precision
|
||||
);
|
||||
if (validCells.includes(entryGeohash)) {
|
||||
wasPresent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const proofId = await sha256(`temporal|${Date.now()}|${generateSalt(8)}`);
|
||||
const timestamp = Date.now();
|
||||
|
||||
const proofData = {
|
||||
precision,
|
||||
historyCount: relevantHistory.length,
|
||||
timeRange,
|
||||
commitmentsRoot: await computeMerkleRoot(
|
||||
relevantHistory.map((h) => h.commitment.commitment)
|
||||
),
|
||||
};
|
||||
|
||||
const signatureMessage = `${proofId}|${timestamp}|${wasPresent}|${timeRange.start}|${timeRange.end}`;
|
||||
const signature = await sha256(`${signatureMessage}|${privateKey}`);
|
||||
|
||||
return {
|
||||
type: 'temporal',
|
||||
proofId,
|
||||
timestamp,
|
||||
proverPublicKey: publicKey,
|
||||
proof: JSON.stringify(proofData),
|
||||
signature,
|
||||
location: targetLocation,
|
||||
timeRange,
|
||||
result: wasPresent,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Compute a simple merkle root from an array of strings
|
||||
*/
|
||||
async function computeMerkleRoot(leaves: string[]): Promise<string> {
|
||||
if (leaves.length === 0) {
|
||||
return sha256('empty');
|
||||
}
|
||||
|
||||
// Sort leaves for deterministic ordering
|
||||
const sortedLeaves = [...leaves].sort();
|
||||
|
||||
// Hash all leaves
|
||||
let currentLevel = await Promise.all(sortedLeaves.map((l) => sha256(l)));
|
||||
|
||||
// Build tree
|
||||
while (currentLevel.length > 1) {
|
||||
const nextLevel: string[] = [];
|
||||
for (let i = 0; i < currentLevel.length; i += 2) {
|
||||
const left = currentLevel[i];
|
||||
const right = currentLevel[i + 1] || left; // Duplicate last if odd
|
||||
nextLevel.push(await sha256(`${left}|${right}`));
|
||||
}
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
return currentLevel[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounding box of a polygon
|
||||
*/
|
||||
function getPolygonBounds(polygon: [number, number][]): {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
} {
|
||||
let minLat = Infinity,
|
||||
maxLat = -Infinity;
|
||||
let minLng = Infinity,
|
||||
maxLng = -Infinity;
|
||||
|
||||
for (const [lat, lng] of polygon) {
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
minLng = Math.min(minLng, lng);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
}
|
||||
|
||||
return { minLat, maxLat, minLng, maxLng };
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine distance between two points (meters)
|
||||
*/
|
||||
function haversineDistance(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number
|
||||
): number {
|
||||
const R = 6371000; // Earth radius in meters
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function toRad(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Quick Proof Utilities
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Quick check if two locations are within a distance
|
||||
* (Used for testing without full proof generation)
|
||||
*/
|
||||
export function areLocationsProximate(
|
||||
loc1: Coordinate,
|
||||
loc2: Coordinate,
|
||||
maxDistanceMeters: number
|
||||
): boolean {
|
||||
const distance = haversineDistance(loc1.lat, loc1.lng, loc2.lat, loc2.lng);
|
||||
return distance <= maxDistanceMeters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if a location is in a region
|
||||
*/
|
||||
export function isLocationInRegion(
|
||||
location: Coordinate,
|
||||
polygon: [number, number][]
|
||||
): boolean {
|
||||
let inside = false;
|
||||
const n = polygon.length;
|
||||
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const [yi, xi] = polygon[i];
|
||||
const [yj, xj] = polygon[j];
|
||||
|
||||
if (
|
||||
yi > location.lat !== yj > location.lat &&
|
||||
location.lng < ((xj - xi) * (location.lat - yi)) / (yj - yi) + xi
|
||||
) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distance between two locations in meters
|
||||
*/
|
||||
export function getDistance(loc1: Coordinate, loc2: Coordinate): number {
|
||||
return haversineDistance(loc1.lat, loc1.lng, loc2.lat, loc2.lng);
|
||||
}
|
||||
|
|
@ -0,0 +1,558 @@
|
|||
/**
|
||||
* Trust Circle Management for zkGPS
|
||||
*
|
||||
* Trust circles define who can see what precision of your location.
|
||||
* Each circle has a trust level that maps to a geohash precision.
|
||||
*
|
||||
* Levels:
|
||||
* intimate: ~1m (exact position) - partners, family in same house
|
||||
* close: ~38m (building level) - close friends, family
|
||||
* friends: ~1.2km (neighborhood) - regular friends
|
||||
* network: ~39km (metro area) - acquaintances
|
||||
* public: ~1250km (large region) - everyone else
|
||||
*/
|
||||
|
||||
import { generateSalt, sha256 } from './commitments';
|
||||
import {
|
||||
TrustCircle,
|
||||
TrustLevel,
|
||||
ContactTrust,
|
||||
TRUST_LEVEL_PRECISION,
|
||||
ZkGpsConfig,
|
||||
DEFAULT_ZKGPS_CONFIG,
|
||||
GeohashPrecision,
|
||||
LocationBroadcast,
|
||||
LocationCommitment,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Trust Circle Manager
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Manages trust circles and contact permissions
|
||||
*/
|
||||
export class TrustCircleManager {
|
||||
private circles: Map<string, TrustCircle> = new Map();
|
||||
private contacts: Map<string, ContactTrust> = new Map();
|
||||
private userId: string;
|
||||
private publicKey: string;
|
||||
|
||||
constructor(userId: string, publicKey: string, config?: Partial<ZkGpsConfig>) {
|
||||
this.userId = userId;
|
||||
this.publicKey = publicKey;
|
||||
|
||||
// Initialize with default circles
|
||||
const defaultCircles = config?.trustCircles ?? DEFAULT_ZKGPS_CONFIG.trustCircles ?? [];
|
||||
for (const circle of defaultCircles) {
|
||||
this.circles.set(circle.id, { ...circle });
|
||||
}
|
||||
|
||||
// Initialize contacts
|
||||
const defaultContacts = config?.contacts ?? [];
|
||||
for (const contact of defaultContacts) {
|
||||
this.contacts.set(contact.contactId, { ...contact });
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Circle Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create a new trust circle
|
||||
*/
|
||||
createCircle(params: {
|
||||
name: string;
|
||||
level: TrustLevel;
|
||||
customPrecision?: GeohashPrecision;
|
||||
updateInterval?: number;
|
||||
requireMutual?: boolean;
|
||||
}): TrustCircle {
|
||||
const circle: TrustCircle = {
|
||||
id: generateSalt(8),
|
||||
name: params.name,
|
||||
level: params.level,
|
||||
customPrecision: params.customPrecision,
|
||||
members: [],
|
||||
updateInterval: params.updateInterval ?? this.getDefaultInterval(params.level),
|
||||
requireMutual: params.requireMutual ?? params.level === 'intimate' || params.level === 'close',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
this.circles.set(circle.id, circle);
|
||||
return circle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default update interval for a trust level (ms)
|
||||
*/
|
||||
private getDefaultInterval(level: TrustLevel): number {
|
||||
switch (level) {
|
||||
case 'intimate':
|
||||
return 10000; // 10 seconds
|
||||
case 'close':
|
||||
return 60000; // 1 minute
|
||||
case 'friends':
|
||||
return 300000; // 5 minutes
|
||||
case 'network':
|
||||
return 900000; // 15 minutes
|
||||
case 'public':
|
||||
return 3600000; // 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a trust circle
|
||||
*/
|
||||
updateCircle(circleId: string, updates: Partial<TrustCircle>): TrustCircle | null {
|
||||
const circle = this.circles.get(circleId);
|
||||
if (!circle) return null;
|
||||
|
||||
const updated = { ...circle, ...updates, id: circleId };
|
||||
this.circles.set(circleId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a trust circle
|
||||
*/
|
||||
deleteCircle(circleId: string): boolean {
|
||||
// Remove circle from all contacts
|
||||
for (const [contactId, contact] of this.contacts) {
|
||||
if (contact.circles.includes(circleId)) {
|
||||
contact.circles = contact.circles.filter((c) => c !== circleId);
|
||||
this.contacts.set(contactId, contact);
|
||||
}
|
||||
}
|
||||
|
||||
return this.circles.delete(circleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a circle by ID
|
||||
*/
|
||||
getCircle(circleId: string): TrustCircle | undefined {
|
||||
return this.circles.get(circleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all circles
|
||||
*/
|
||||
getAllCircles(): TrustCircle[] {
|
||||
return Array.from(this.circles.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled circles
|
||||
*/
|
||||
getEnabledCircles(): TrustCircle[] {
|
||||
return Array.from(this.circles.values()).filter((c) => c.enabled);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Member Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Add a contact to a circle
|
||||
*/
|
||||
addToCircle(circleId: string, contactId: string): boolean {
|
||||
const circle = this.circles.get(circleId);
|
||||
if (!circle) return false;
|
||||
|
||||
if (!circle.members.includes(contactId)) {
|
||||
circle.members.push(contactId);
|
||||
}
|
||||
|
||||
// Update or create contact trust record
|
||||
let contact = this.contacts.get(contactId);
|
||||
if (!contact) {
|
||||
contact = {
|
||||
contactId,
|
||||
circles: [],
|
||||
paused: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!contact.circles.includes(circleId)) {
|
||||
contact.circles.push(circleId);
|
||||
}
|
||||
|
||||
this.contacts.set(contactId, contact);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a contact from a circle
|
||||
*/
|
||||
removeFromCircle(circleId: string, contactId: string): boolean {
|
||||
const circle = this.circles.get(circleId);
|
||||
if (!circle) return false;
|
||||
|
||||
circle.members = circle.members.filter((m) => m !== contactId);
|
||||
|
||||
const contact = this.contacts.get(contactId);
|
||||
if (contact) {
|
||||
contact.circles = contact.circles.filter((c) => c !== circleId);
|
||||
this.contacts.set(contactId, contact);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a contact is in a circle
|
||||
*/
|
||||
isInCircle(circleId: string, contactId: string): boolean {
|
||||
const circle = this.circles.get(circleId);
|
||||
return circle?.members.includes(contactId) ?? false;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Contact Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get contact trust settings
|
||||
*/
|
||||
getContactTrust(contactId: string): ContactTrust | undefined {
|
||||
return this.contacts.get(contactId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a precision override for a specific contact
|
||||
*/
|
||||
setContactPrecision(contactId: string, precision: GeohashPrecision | undefined): void {
|
||||
let contact = this.contacts.get(contactId);
|
||||
if (!contact) {
|
||||
contact = {
|
||||
contactId,
|
||||
circles: [],
|
||||
paused: false,
|
||||
};
|
||||
}
|
||||
contact.precisionOverride = precision;
|
||||
this.contacts.set(contactId, contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause location sharing with a contact
|
||||
*/
|
||||
pauseContact(contactId: string): void {
|
||||
let contact = this.contacts.get(contactId);
|
||||
if (!contact) {
|
||||
contact = {
|
||||
contactId,
|
||||
circles: [],
|
||||
paused: true,
|
||||
};
|
||||
} else {
|
||||
contact.paused = true;
|
||||
}
|
||||
this.contacts.set(contactId, contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume location sharing with a contact
|
||||
*/
|
||||
resumeContact(contactId: string): void {
|
||||
const contact = this.contacts.get(contactId);
|
||||
if (contact) {
|
||||
contact.paused = false;
|
||||
this.contacts.set(contactId, contact);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Precision Resolution
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get the precision level for a specific contact
|
||||
*
|
||||
* Priority:
|
||||
* 1. Contact-specific override
|
||||
* 2. Highest precision circle they belong to
|
||||
* 3. Public level (or no sharing if not in any circle)
|
||||
*/
|
||||
getPrecisionForContact(contactId: string): GeohashPrecision | null {
|
||||
const contact = this.contacts.get(contactId);
|
||||
|
||||
// If contact is paused, no sharing
|
||||
if (contact?.paused) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for override
|
||||
if (contact?.precisionOverride !== undefined) {
|
||||
return contact.precisionOverride;
|
||||
}
|
||||
|
||||
// Find highest precision circle
|
||||
let highestPrecision: GeohashPrecision | null = null;
|
||||
|
||||
for (const circle of this.circles.values()) {
|
||||
if (!circle.enabled) continue;
|
||||
if (!circle.members.includes(contactId)) continue;
|
||||
|
||||
const circlePrecision = circle.customPrecision ?? TRUST_LEVEL_PRECISION[circle.level];
|
||||
|
||||
if (highestPrecision === null || circlePrecision > highestPrecision) {
|
||||
highestPrecision = circlePrecision;
|
||||
}
|
||||
}
|
||||
|
||||
return highestPrecision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contacts at a specific precision level or higher
|
||||
*/
|
||||
getContactsAtPrecision(minPrecision: GeohashPrecision): string[] {
|
||||
const contacts: string[] = [];
|
||||
|
||||
for (const [contactId] of this.contacts) {
|
||||
const precision = this.getPrecisionForContact(contactId);
|
||||
if (precision !== null && precision >= minPrecision) {
|
||||
contacts.push(contactId);
|
||||
}
|
||||
}
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get precision level for a trust level
|
||||
*/
|
||||
getPrecisionForLevel(level: TrustLevel): GeohashPrecision {
|
||||
return TRUST_LEVEL_PRECISION[level];
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Broadcast Helpers
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Determine which circles need to receive a location update
|
||||
* based on time since last update
|
||||
*/
|
||||
getCirclesNeedingUpdate(lastUpdateTimes: Map<string, number>): TrustCircle[] {
|
||||
const now = Date.now();
|
||||
const needsUpdate: TrustCircle[] = [];
|
||||
|
||||
for (const circle of this.circles.values()) {
|
||||
if (!circle.enabled) continue;
|
||||
if (circle.members.length === 0) continue;
|
||||
|
||||
const lastUpdate = lastUpdateTimes.get(circle.id) ?? 0;
|
||||
if (now - lastUpdate >= circle.updateInterval) {
|
||||
needsUpdate.push(circle);
|
||||
}
|
||||
}
|
||||
|
||||
return needsUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create commitments for each circle based on current location
|
||||
* Returns a map of circleId -> encrypted commitment
|
||||
*/
|
||||
async createCircleCommitments(
|
||||
coordinate: { lat: number; lng: number },
|
||||
createCommitmentFn: (precision: GeohashPrecision) => Promise<LocationCommitment>
|
||||
): Promise<Map<string, { commitment: LocationCommitment; precision: GeohashPrecision }>> {
|
||||
const commitments = new Map<
|
||||
string,
|
||||
{ commitment: LocationCommitment; precision: GeohashPrecision }
|
||||
>();
|
||||
|
||||
for (const circle of this.circles.values()) {
|
||||
if (!circle.enabled) continue;
|
||||
if (circle.members.length === 0) continue;
|
||||
|
||||
const precision = circle.customPrecision ?? TRUST_LEVEL_PRECISION[circle.level];
|
||||
const commitment = await createCommitmentFn(precision);
|
||||
|
||||
commitments.set(circle.id, { commitment, precision });
|
||||
}
|
||||
|
||||
return commitments;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Mutual Verification
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Check if mutual membership requirement is satisfied
|
||||
*
|
||||
* @param theirUserId The other user's ID
|
||||
* @param theirCircles Their trust circles (if known)
|
||||
* @returns true if mutual requirement is satisfied
|
||||
*/
|
||||
checkMutualMembership(
|
||||
theirUserId: string,
|
||||
theirCircles?: Map<string, TrustCircle>
|
||||
): boolean {
|
||||
// Get circles that require mutual membership and include them
|
||||
const ourMutualCircles = Array.from(this.circles.values()).filter(
|
||||
(c) => c.requireMutual && c.members.includes(theirUserId)
|
||||
);
|
||||
|
||||
if (ourMutualCircles.length === 0) {
|
||||
// No mutual circles containing them
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!theirCircles) {
|
||||
// We require mutual but don't have their circles - fail
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if they have us in any of their mutual circles
|
||||
for (const theirCircle of theirCircles.values()) {
|
||||
if (theirCircle.requireMutual && theirCircle.members.includes(this.userId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Serialization
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Export configuration for storage
|
||||
*/
|
||||
export(): { circles: TrustCircle[]; contacts: ContactTrust[] } {
|
||||
return {
|
||||
circles: Array.from(this.circles.values()),
|
||||
contacts: Array.from(this.contacts.values()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import configuration from storage
|
||||
*/
|
||||
import(data: { circles: TrustCircle[]; contacts: ContactTrust[] }): void {
|
||||
this.circles.clear();
|
||||
this.contacts.clear();
|
||||
|
||||
for (const circle of data.circles) {
|
||||
this.circles.set(circle.id, circle);
|
||||
}
|
||||
|
||||
for (const contact of data.contacts) {
|
||||
this.contacts.set(contact.contactId, contact);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics
|
||||
*/
|
||||
getStats(): {
|
||||
circleCount: number;
|
||||
enabledCircleCount: number;
|
||||
contactCount: number;
|
||||
pausedContactCount: number;
|
||||
} {
|
||||
const circles = Array.from(this.circles.values());
|
||||
const contacts = Array.from(this.contacts.values());
|
||||
|
||||
return {
|
||||
circleCount: circles.length,
|
||||
enabledCircleCount: circles.filter((c) => c.enabled).length,
|
||||
contactCount: contacts.length,
|
||||
pausedContactCount: contacts.filter((c) => c.paused).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a trust circle manager with default configuration
|
||||
*/
|
||||
export function createTrustCircleManager(
|
||||
userId: string,
|
||||
publicKey: string
|
||||
): TrustCircleManager {
|
||||
return new TrustCircleManager(userId, publicKey, DEFAULT_ZKGPS_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a trust circle manager from saved configuration
|
||||
*/
|
||||
export function loadTrustCircleManager(
|
||||
userId: string,
|
||||
publicKey: string,
|
||||
savedConfig: { circles: TrustCircle[]; contacts: ContactTrust[] }
|
||||
): TrustCircleManager {
|
||||
const manager = new TrustCircleManager(userId, publicKey, {
|
||||
trustCircles: [],
|
||||
contacts: [],
|
||||
});
|
||||
manager.import(savedConfig);
|
||||
return manager;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get human-readable description of trust level
|
||||
*/
|
||||
export function describeTrustLevel(level: TrustLevel): string {
|
||||
const descriptions: Record<TrustLevel, string> = {
|
||||
intimate: 'Exact location (~1m) - Partners, family in same house',
|
||||
close: 'Building level (~38m) - Close friends and family',
|
||||
friends: 'Neighborhood (~1.2km) - Regular friends',
|
||||
network: 'Metro area (~39km) - Acquaintances',
|
||||
public: 'Large region (~1250km) - Public visibility',
|
||||
};
|
||||
return descriptions[level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trust level from precision
|
||||
*/
|
||||
export function getTrustLevelFromPrecision(precision: GeohashPrecision): TrustLevel {
|
||||
if (precision >= 10) return 'intimate';
|
||||
if (precision >= 8) return 'close';
|
||||
if (precision >= 6) return 'friends';
|
||||
if (precision >= 4) return 'network';
|
||||
return 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a trust circle configuration
|
||||
*/
|
||||
export function validateCircle(circle: Partial<TrustCircle>): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!circle.name || circle.name.trim().length === 0) {
|
||||
errors.push('Circle name is required');
|
||||
}
|
||||
|
||||
if (!circle.level || !['intimate', 'close', 'friends', 'network', 'public'].includes(circle.level)) {
|
||||
errors.push('Invalid trust level');
|
||||
}
|
||||
|
||||
if (circle.customPrecision !== undefined) {
|
||||
if (circle.customPrecision < 1 || circle.customPrecision > 12) {
|
||||
errors.push('Custom precision must be between 1 and 12');
|
||||
}
|
||||
}
|
||||
|
||||
if (circle.updateInterval !== undefined && circle.updateInterval < 1000) {
|
||||
errors.push('Update interval must be at least 1 second (1000ms)');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
/**
|
||||
* zkGPS Type Definitions
|
||||
*
|
||||
* Types for privacy-preserving location sharing protocol
|
||||
*/
|
||||
|
||||
import type { GeohashPrecision } from './geohash';
|
||||
|
||||
// =============================================================================
|
||||
// Core Location Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A geographic coordinate
|
||||
*/
|
||||
export interface Coordinate {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamped location record
|
||||
*/
|
||||
export interface TimestampedLocation {
|
||||
coordinate: Coordinate;
|
||||
timestamp: number; // Unix timestamp (ms)
|
||||
accuracy?: number; // Reported GPS accuracy (meters)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Commitment Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A cryptographic commitment to a location
|
||||
*/
|
||||
export interface LocationCommitment {
|
||||
/** The commitment hash */
|
||||
commitment: string;
|
||||
|
||||
/** Precision level (1-12) - determines how much location is revealed */
|
||||
precision: GeohashPrecision;
|
||||
|
||||
/** When this commitment was created */
|
||||
timestamp: number;
|
||||
|
||||
/** When this commitment expires */
|
||||
expiresAt: number;
|
||||
|
||||
/** Optional: the geohash prefix that is publicly revealed */
|
||||
revealedPrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for creating a commitment
|
||||
*/
|
||||
export interface CommitmentParams {
|
||||
coordinate: Coordinate;
|
||||
precision: GeohashPrecision;
|
||||
salt: string;
|
||||
expirationMs?: number; // How long until commitment expires
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed location commitment (for temporal proofs)
|
||||
*/
|
||||
export interface SignedCommitment extends LocationCommitment {
|
||||
/** Digital signature over the commitment */
|
||||
signature: string;
|
||||
|
||||
/** Public key of the signer */
|
||||
signerPublicKey: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Trust Circle Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Trust levels for location sharing
|
||||
*/
|
||||
export type TrustLevel = 'intimate' | 'close' | 'friends' | 'network' | 'public';
|
||||
|
||||
/**
|
||||
* Default precision mappings for trust levels
|
||||
*/
|
||||
export const TRUST_LEVEL_PRECISION: Record<TrustLevel, GeohashPrecision> = {
|
||||
intimate: 10, // ~1.2m - exact position
|
||||
close: 8, // ~38m - building level
|
||||
friends: 6, // ~1.2km - neighborhood
|
||||
network: 4, // ~39km - metro area
|
||||
public: 2, // ~1250km - large region (or don't share at all)
|
||||
};
|
||||
|
||||
/**
|
||||
* A trust circle configuration
|
||||
*/
|
||||
export interface TrustCircle {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Display name */
|
||||
name: string;
|
||||
|
||||
/** Trust level (determines default precision) */
|
||||
level: TrustLevel;
|
||||
|
||||
/** Override precision (if different from level default) */
|
||||
customPrecision?: GeohashPrecision;
|
||||
|
||||
/** Member identifiers (user IDs or public keys) */
|
||||
members: string[];
|
||||
|
||||
/** How often to broadcast location to this circle (ms) */
|
||||
updateInterval: number;
|
||||
|
||||
/** Require mutual membership (both must have each other in circles) */
|
||||
requireMutual: boolean;
|
||||
|
||||
/** Whether this circle is currently active */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust circle membership for a specific contact
|
||||
*/
|
||||
export interface ContactTrust {
|
||||
/** Contact's user ID or public key */
|
||||
contactId: string;
|
||||
|
||||
/** Which trust circles they belong to */
|
||||
circles: string[];
|
||||
|
||||
/** Explicit precision override for this contact */
|
||||
precisionOverride?: GeohashPrecision;
|
||||
|
||||
/** Whether location sharing is paused for this contact */
|
||||
paused: boolean;
|
||||
|
||||
/** When sharing was last updated */
|
||||
lastUpdate?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Proof Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Types of proofs supported by zkGPS
|
||||
*/
|
||||
export type ProofType = 'proximity' | 'region' | 'temporal' | 'group';
|
||||
|
||||
/**
|
||||
* Base proof structure
|
||||
*/
|
||||
export interface BaseProof {
|
||||
/** Type of proof */
|
||||
type: ProofType;
|
||||
|
||||
/** Unique proof identifier */
|
||||
proofId: string;
|
||||
|
||||
/** When the proof was generated */
|
||||
timestamp: number;
|
||||
|
||||
/** Public key of the prover */
|
||||
proverPublicKey: string;
|
||||
|
||||
/** The actual proof data (format depends on type) */
|
||||
proof: string;
|
||||
|
||||
/** Signature over the proof */
|
||||
signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proximity proof: "I am within X meters of point P"
|
||||
*/
|
||||
export interface ProximityProof extends BaseProof {
|
||||
type: 'proximity';
|
||||
|
||||
/** The target point (public) */
|
||||
targetPoint: Coordinate;
|
||||
|
||||
/** Maximum distance claimed (meters) */
|
||||
maxDistance: number;
|
||||
|
||||
/** Result: true if prover is within distance */
|
||||
result: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Region membership proof: "I am inside region R"
|
||||
*/
|
||||
export interface RegionProof extends BaseProof {
|
||||
type: 'region';
|
||||
|
||||
/** Region identifier (hash of polygon or named region) */
|
||||
regionId: string;
|
||||
|
||||
/** Human-readable region name */
|
||||
regionName?: string;
|
||||
|
||||
/** Result: true if prover is inside region */
|
||||
result: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporal proof: "I was at location L between T1 and T2"
|
||||
*/
|
||||
export interface TemporalProof extends BaseProof {
|
||||
type: 'temporal';
|
||||
|
||||
/** Region or point being proven */
|
||||
location: Coordinate | string; // Coordinate or region ID
|
||||
|
||||
/** Time range for the proof */
|
||||
timeRange: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
/** Result: true if prover was present during time range */
|
||||
result: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group proximity proof: "All participants are within X meters"
|
||||
*/
|
||||
export interface GroupProximityProof extends BaseProof {
|
||||
type: 'group';
|
||||
|
||||
/** Participant public keys */
|
||||
participants: string[];
|
||||
|
||||
/** Maximum pairwise distance (meters) */
|
||||
maxDistance: number;
|
||||
|
||||
/** Result: true if all participants are proximate */
|
||||
result: boolean;
|
||||
|
||||
/** Optional: centroid of the group (if proof succeeded) */
|
||||
centroid?: Coordinate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all proof types
|
||||
*/
|
||||
export type Proof = ProximityProof | RegionProof | TemporalProof | GroupProximityProof;
|
||||
|
||||
// =============================================================================
|
||||
// Protocol Message Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Location broadcast message
|
||||
*/
|
||||
export interface LocationBroadcast {
|
||||
version: 1;
|
||||
type: 'location_broadcast';
|
||||
|
||||
/** Sender identification */
|
||||
senderId: string;
|
||||
senderPublicKey: string;
|
||||
|
||||
/** Commitments for each trust circle (encrypted) */
|
||||
commitments: {
|
||||
trustCircleId: string;
|
||||
encryptedCommitment: string;
|
||||
precision: GeohashPrecision;
|
||||
}[];
|
||||
|
||||
/** Timestamp */
|
||||
timestamp: number;
|
||||
|
||||
/** Signature over entire message */
|
||||
signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proximity query message
|
||||
*/
|
||||
export interface ProximityQuery {
|
||||
version: 1;
|
||||
type: 'proximity_query';
|
||||
|
||||
/** Query identification */
|
||||
queryId: string;
|
||||
queryer: string;
|
||||
queryerPublicKey: string;
|
||||
|
||||
/** Target user */
|
||||
targetUserId: string;
|
||||
|
||||
/** Query parameters */
|
||||
maxDistance: number;
|
||||
|
||||
/** Our commitment (for mutual verification) */
|
||||
ourCommitment: LocationCommitment;
|
||||
|
||||
/** Timestamp */
|
||||
timestamp: number;
|
||||
|
||||
/** Signature */
|
||||
signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proximity response message
|
||||
*/
|
||||
export interface ProximityResponse {
|
||||
version: 1;
|
||||
type: 'proximity_response';
|
||||
|
||||
/** Query this responds to */
|
||||
queryId: string;
|
||||
|
||||
/** Responder identification */
|
||||
responder: string;
|
||||
responderPublicKey: string;
|
||||
|
||||
/** Response */
|
||||
isProximate: boolean;
|
||||
proof?: ProximityProof;
|
||||
|
||||
/** Timestamp */
|
||||
timestamp: number;
|
||||
|
||||
/** Signature */
|
||||
signature: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Configuration Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* zkGPS service configuration
|
||||
*/
|
||||
export interface ZkGpsConfig {
|
||||
/** User's key pair for signing */
|
||||
keyPair: {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
/** Default trust circles */
|
||||
trustCircles: TrustCircle[];
|
||||
|
||||
/** Contact-specific trust settings */
|
||||
contacts: ContactTrust[];
|
||||
|
||||
/** Location update settings */
|
||||
locationSettings: {
|
||||
/** Minimum time between location updates (ms) */
|
||||
minUpdateInterval: number;
|
||||
|
||||
/** Maximum age of a valid commitment (ms) */
|
||||
maxCommitmentAge: number;
|
||||
|
||||
/** Whether to log location history (for temporal proofs) */
|
||||
enableHistory: boolean;
|
||||
|
||||
/** How long to retain history (ms) */
|
||||
historyRetention: number;
|
||||
};
|
||||
|
||||
/** Proof settings */
|
||||
proofSettings: {
|
||||
/** Whether to generate full ZK proofs (vs simple prefix matching) */
|
||||
useZkProofs: boolean;
|
||||
|
||||
/** Minimum precision for any proof */
|
||||
minProofPrecision: GeohashPrecision;
|
||||
|
||||
/** Rate limit for incoming queries (queries per minute) */
|
||||
queryRateLimit: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
export const DEFAULT_ZKGPS_CONFIG: Partial<ZkGpsConfig> = {
|
||||
trustCircles: [
|
||||
{
|
||||
id: 'intimate',
|
||||
name: 'Intimate',
|
||||
level: 'intimate',
|
||||
members: [],
|
||||
updateInterval: 10000, // 10 seconds
|
||||
requireMutual: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
name: 'Close Friends & Family',
|
||||
level: 'close',
|
||||
members: [],
|
||||
updateInterval: 60000, // 1 minute
|
||||
requireMutual: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'friends',
|
||||
name: 'Friends',
|
||||
level: 'friends',
|
||||
members: [],
|
||||
updateInterval: 300000, // 5 minutes
|
||||
requireMutual: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
name: 'Network',
|
||||
level: 'network',
|
||||
members: [],
|
||||
updateInterval: 900000, // 15 minutes
|
||||
requireMutual: false,
|
||||
enabled: false, // Off by default
|
||||
},
|
||||
],
|
||||
contacts: [],
|
||||
locationSettings: {
|
||||
minUpdateInterval: 5000, // 5 seconds minimum
|
||||
maxCommitmentAge: 300000, // 5 minutes
|
||||
enableHistory: false,
|
||||
historyRetention: 86400000, // 24 hours
|
||||
},
|
||||
proofSettings: {
|
||||
useZkProofs: false, // Start with simple prefix matching
|
||||
minProofPrecision: 4, // Never reveal more than metro-level in proofs
|
||||
queryRateLimit: 10, // 10 queries per minute max
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
/**
|
||||
* Geo-Canvas Coordinate Transformation Utilities
|
||||
*
|
||||
* Provides bidirectional transformation between geographic coordinates (lat/lng)
|
||||
* and canvas coordinates (x/y pixels). Supports multiple projection methods.
|
||||
*
|
||||
* Key concepts:
|
||||
* - Geographic coords: lat/lng (WGS84)
|
||||
* - Canvas coords: x/y pixels in tldraw infinite canvas space
|
||||
* - Tile coords: z/x/y for OSM-style tile addressing
|
||||
* - Web Mercator: The projection used by web maps (EPSG:3857)
|
||||
*/
|
||||
|
||||
import type { Coordinate, BoundingBox, MapViewport } from '../types';
|
||||
|
||||
// Earth radius in meters (WGS84)
|
||||
const EARTH_RADIUS = 6378137;
|
||||
|
||||
// Maximum latitude for Web Mercator projection (approximately)
|
||||
const MAX_LATITUDE = 85.05112878;
|
||||
|
||||
/**
|
||||
* Geographic coordinate anchor point for canvas-geo mapping.
|
||||
* Defines where on the canvas a specific lat/lng maps to.
|
||||
*/
|
||||
export interface GeoAnchor {
|
||||
geo: Coordinate;
|
||||
canvas: { x: number; y: number };
|
||||
zoom: number; // Map zoom level (affects scale)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for geo-canvas transformation
|
||||
*/
|
||||
export interface GeoTransformConfig {
|
||||
anchor: GeoAnchor;
|
||||
tileSize?: number; // Default 256
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees to radians
|
||||
*/
|
||||
export function toRadians(degrees: number): number {
|
||||
return (degrees * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert radians to degrees
|
||||
*/
|
||||
export function toDegrees(radians: number): number {
|
||||
return (radians * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp latitude to valid Web Mercator range
|
||||
*/
|
||||
export function clampLatitude(lat: number): number {
|
||||
return Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, lat));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lat/lng to Web Mercator projected coordinates (meters)
|
||||
*/
|
||||
export function geoToMercator(coord: Coordinate): { x: number; y: number } {
|
||||
const lat = clampLatitude(coord.lat);
|
||||
const x = EARTH_RADIUS * toRadians(coord.lng);
|
||||
const y = EARTH_RADIUS * Math.log(Math.tan(Math.PI / 4 + toRadians(lat) / 2));
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Web Mercator coordinates (meters) to lat/lng
|
||||
*/
|
||||
export function mercatorToGeo(point: { x: number; y: number }): Coordinate {
|
||||
const lng = toDegrees(point.x / EARTH_RADIUS);
|
||||
const lat = toDegrees(2 * Math.atan(Math.exp(point.y / EARTH_RADIUS)) - Math.PI / 2);
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scale factor at a given zoom level
|
||||
* At zoom 0, the world is 256px wide (1 tile)
|
||||
* At zoom n, the world is 256 * 2^n px wide
|
||||
*/
|
||||
export function getScaleAtZoom(zoom: number, tileSize: number = 256): number {
|
||||
return tileSize * Math.pow(2, zoom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lat/lng to pixel coordinates at a given zoom level
|
||||
* Origin (0,0) is at lat=85.05, lng=-180 (top-left of the world)
|
||||
*/
|
||||
export function geoToPixel(
|
||||
coord: Coordinate,
|
||||
zoom: number,
|
||||
tileSize: number = 256
|
||||
): { x: number; y: number } {
|
||||
const scale = getScaleAtZoom(zoom, tileSize);
|
||||
const lat = clampLatitude(coord.lat);
|
||||
|
||||
// Longitude: linear mapping from -180..180 to 0..scale
|
||||
const x = ((coord.lng + 180) / 360) * scale;
|
||||
|
||||
// Latitude: Mercator projection
|
||||
const latRad = toRadians(lat);
|
||||
const y = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * scale;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert pixel coordinates back to lat/lng
|
||||
*/
|
||||
export function pixelToGeo(
|
||||
point: { x: number; y: number },
|
||||
zoom: number,
|
||||
tileSize: number = 256
|
||||
): Coordinate {
|
||||
const scale = getScaleAtZoom(zoom, tileSize);
|
||||
|
||||
// Longitude: linear mapping
|
||||
const lng = (point.x / scale) * 360 - 180;
|
||||
|
||||
// Latitude: inverse Mercator
|
||||
const n = Math.PI - (2 * Math.PI * point.y) / scale;
|
||||
const lat = toDegrees(Math.atan(Math.sinh(n)));
|
||||
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoCanvasTransform - Main class for transforming between geo and canvas coordinates
|
||||
*/
|
||||
export class GeoCanvasTransform {
|
||||
private anchor: GeoAnchor;
|
||||
private tileSize: number;
|
||||
|
||||
constructor(config: GeoTransformConfig) {
|
||||
this.anchor = config.anchor;
|
||||
this.tileSize = config.tileSize ?? 256;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current anchor point
|
||||
*/
|
||||
getAnchor(): GeoAnchor {
|
||||
return { ...this.anchor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the anchor point (e.g., when user pans/zooms)
|
||||
*/
|
||||
setAnchor(anchor: GeoAnchor): void {
|
||||
this.anchor = anchor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update zoom level while keeping the anchor geo-point at the same canvas position
|
||||
*/
|
||||
setZoom(zoom: number): void {
|
||||
this.anchor = { ...this.anchor, zoom };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert geographic coordinates to canvas coordinates
|
||||
*/
|
||||
geoToCanvas(coord: Coordinate): { x: number; y: number } {
|
||||
// Get pixel coords for both the target and anchor at current zoom
|
||||
const targetPixel = geoToPixel(coord, this.anchor.zoom, this.tileSize);
|
||||
const anchorPixel = geoToPixel(this.anchor.geo, this.anchor.zoom, this.tileSize);
|
||||
|
||||
// Calculate offset from anchor
|
||||
const dx = targetPixel.x - anchorPixel.x;
|
||||
const dy = targetPixel.y - anchorPixel.y;
|
||||
|
||||
// Apply to canvas anchor position
|
||||
return {
|
||||
x: this.anchor.canvas.x + dx,
|
||||
y: this.anchor.canvas.y + dy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert canvas coordinates to geographic coordinates
|
||||
*/
|
||||
canvasToGeo(point: { x: number; y: number }): Coordinate {
|
||||
// Get anchor pixel position
|
||||
const anchorPixel = geoToPixel(this.anchor.geo, this.anchor.zoom, this.tileSize);
|
||||
|
||||
// Calculate offset from canvas anchor
|
||||
const dx = point.x - this.anchor.canvas.x;
|
||||
const dy = point.y - this.anchor.canvas.y;
|
||||
|
||||
// Apply to pixel coords
|
||||
const targetPixel = {
|
||||
x: anchorPixel.x + dx,
|
||||
y: anchorPixel.y + dy,
|
||||
};
|
||||
|
||||
return pixelToGeo(targetPixel, this.anchor.zoom, this.tileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the geographic bounds visible in a canvas viewport
|
||||
*/
|
||||
canvasBoundsToGeo(bounds: { x: number; y: number; w: number; h: number }): BoundingBox {
|
||||
const topLeft = this.canvasToGeo({ x: bounds.x, y: bounds.y });
|
||||
const bottomRight = this.canvasToGeo({ x: bounds.x + bounds.w, y: bounds.y + bounds.h });
|
||||
|
||||
return {
|
||||
north: topLeft.lat,
|
||||
south: bottomRight.lat,
|
||||
east: bottomRight.lng,
|
||||
west: topLeft.lng,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get canvas bounds for a geographic bounding box
|
||||
*/
|
||||
geoBoundsToCanvas(bounds: BoundingBox): { x: number; y: number; w: number; h: number } {
|
||||
const topLeft = this.geoToCanvas({ lat: bounds.north, lng: bounds.west });
|
||||
const bottomRight = this.geoToCanvas({ lat: bounds.south, lng: bounds.east });
|
||||
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
w: bottomRight.x - topLeft.x,
|
||||
h: bottomRight.y - topLeft.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meters per pixel at the current zoom level at a given latitude
|
||||
*/
|
||||
getMetersPerPixel(lat: number = 0): number {
|
||||
const circumference = 2 * Math.PI * EARTH_RADIUS * Math.cos(toRadians(lat));
|
||||
const scale = getScaleAtZoom(this.anchor.zoom, this.tileSize);
|
||||
return circumference / scale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two canvas points in meters
|
||||
*/
|
||||
canvasDistanceToMeters(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
||||
const geo1 = this.canvasToGeo(p1);
|
||||
const geo2 = this.canvasToGeo(p2);
|
||||
return haversineDistance(geo1, geo2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine distance between two geographic coordinates (meters)
|
||||
*/
|
||||
export function haversineDistance(a: Coordinate, b: Coordinate): number {
|
||||
const dLat = toRadians(b.lat - a.lat);
|
||||
const dLng = toRadians(b.lng - a.lng);
|
||||
const lat1 = toRadians(a.lat);
|
||||
const lat2 = toRadians(b.lat);
|
||||
|
||||
const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tile coordinates for a given lat/lng and zoom
|
||||
*/
|
||||
export function geoToTile(coord: Coordinate, zoom: number): { x: number; y: number; z: number } {
|
||||
const n = Math.pow(2, zoom);
|
||||
const x = Math.floor(((coord.lng + 180) / 360) * n);
|
||||
const latRad = toRadians(clampLatitude(coord.lat));
|
||||
const y = Math.floor(((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n);
|
||||
return { x, y, z: zoom };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the center lat/lng of a tile
|
||||
*/
|
||||
export function tileCenterToGeo(x: number, y: number, z: number): Coordinate {
|
||||
const n = Math.pow(2, z);
|
||||
const lng = ((x + 0.5) / n) * 360 - 180;
|
||||
const latRad = Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 0.5)) / n)));
|
||||
return { lat: toDegrees(latRad), lng };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bounding box of a tile
|
||||
*/
|
||||
export function tileBounds(x: number, y: number, z: number): BoundingBox {
|
||||
const n = Math.pow(2, z);
|
||||
const west = (x / n) * 360 - 180;
|
||||
const east = ((x + 1) / n) * 360 - 180;
|
||||
const north = toDegrees(Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))));
|
||||
const south = toDegrees(Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))));
|
||||
return { north, south, east, west };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default GeoCanvasTransform centered at a location
|
||||
*/
|
||||
export function createDefaultTransform(
|
||||
center: Coordinate = { lat: 0, lng: 0 },
|
||||
canvasCenter: { x: number; y: number } = { x: 0, y: 0 },
|
||||
zoom: number = 10
|
||||
): GeoCanvasTransform {
|
||||
return new GeoCanvasTransform({
|
||||
anchor: {
|
||||
geo: center,
|
||||
canvas: canvasCenter,
|
||||
zoom,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@
|
|||
|
||||
import type { Coordinate, BoundingBox } from '../types';
|
||||
|
||||
// Re-export geo transform utilities
|
||||
export * from './geoTransform';
|
||||
|
||||
export function haversineDistance(a: Coordinate, b: Coordinate): number {
|
||||
const R = 6371000;
|
||||
const lat1 = (a.lat * Math.PI) / 180;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ import { MultmuxTool } from "@/tools/MultmuxTool"
|
|||
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
|
||||
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
|
||||
import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil"
|
||||
// Open Mapping - OSM map shape for geographic visualization
|
||||
import { MapShape } from "@/shapes/MapShapeUtil"
|
||||
import { MapTool } from "@/tools/MapTool"
|
||||
import {
|
||||
lockElement,
|
||||
unlockElement,
|
||||
|
|
@ -61,6 +64,7 @@ import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
|
|||
import { GestureTool } from "@/GestureTool"
|
||||
import { CmdK } from "@/CmdK"
|
||||
import { setupMultiPasteHandler } from "@/utils/multiPasteHandler"
|
||||
import { ConnectionStatusIndicator } from "@/components/ConnectionStatusIndicator"
|
||||
|
||||
|
||||
import "react-cmdk/dist/cmdk.css"
|
||||
|
|
@ -141,6 +145,7 @@ const customShapeUtils = [
|
|||
VideoGenShape,
|
||||
MultmuxShape,
|
||||
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
|
||||
MapShape, // Open Mapping - OSM map shape
|
||||
]
|
||||
const customTools = [
|
||||
ChatBoxTool,
|
||||
|
|
@ -158,6 +163,7 @@ const customTools = [
|
|||
ImageGenTool,
|
||||
VideoGenTool,
|
||||
MultmuxTool,
|
||||
MapTool, // Open Mapping - OSM map tool
|
||||
]
|
||||
|
||||
// Debug: Log tool and shape registration info
|
||||
|
|
@ -370,13 +376,13 @@ export function Board() {
|
|||
|
||||
// Use Automerge sync for all environments
|
||||
const storeWithHandle = useAutomergeSync(storeConfig)
|
||||
const store = {
|
||||
store: storeWithHandle.store,
|
||||
const store = {
|
||||
store: storeWithHandle.store,
|
||||
status: storeWithHandle.status,
|
||||
...('connectionStatus' in storeWithHandle ? { connectionStatus: storeWithHandle.connectionStatus } : {}),
|
||||
error: storeWithHandle.error
|
||||
}
|
||||
const automergeHandle = (storeWithHandle as any).handle
|
||||
const { connectionState, isNetworkOnline } = storeWithHandle
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1101,6 +1107,10 @@ export function Board() {
|
|||
>
|
||||
<CmdK />
|
||||
</Tldraw>
|
||||
<ConnectionStatusIndicator
|
||||
connectionState={connectionState}
|
||||
isNetworkOnline={isNetworkOnline}
|
||||
/>
|
||||
</div>
|
||||
</AutomergeHandleProvider>
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* MapTool - Tool for placing Map shapes on the canvas
|
||||
*/
|
||||
|
||||
import { BaseBoxShapeTool } from 'tldraw';
|
||||
|
||||
export class MapTool extends BaseBoxShapeTool {
|
||||
static override id = 'map';
|
||||
static override initial = 'idle';
|
||||
override shapeType = 'Map';
|
||||
}
|
||||
|
||||
export default MapTool;
|
||||
|
|
@ -239,6 +239,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
<TldrawUiMenuItem {...tools.Embed} />
|
||||
<TldrawUiMenuItem {...tools.Holon} />
|
||||
<TldrawUiMenuItem {...tools.Multmux} />
|
||||
<TldrawUiMenuItem {...tools.Map} />
|
||||
<TldrawUiMenuItem {...tools.SlideShape} />
|
||||
<TldrawUiMenuItem {...tools.VideoChat} />
|
||||
<TldrawUiMenuItem {...tools.FathomMeetings} />
|
||||
|
|
|
|||
|
|
@ -1018,6 +1018,14 @@ export function CustomToolbar() {
|
|||
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Map"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Map"]}
|
||||
icon="geo-globe"
|
||||
label="Map"
|
||||
isSelected={tools["Map"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{/* MycelialIntelligence moved to permanent floating bar */}
|
||||
{/* Share Location tool removed for now */}
|
||||
{/* Refresh All ObsNotes Button */}
|
||||
|
|
|
|||
|
|
@ -230,6 +230,14 @@ export const overrides: TLUiOverrides = {
|
|||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("Multmux"),
|
||||
},
|
||||
Map: {
|
||||
id: "Map",
|
||||
icon: "geo-globe",
|
||||
label: "Map",
|
||||
kbd: "ctrl+shift+m",
|
||||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("map"),
|
||||
},
|
||||
// MycelialIntelligence removed - now a permanent UI bar (MycelialIntelligenceBar.tsx)
|
||||
hand: {
|
||||
...tools.hand,
|
||||
|
|
|
|||
|
|
@ -87,7 +87,9 @@ export default defineConfig(({ mode }) => {
|
|||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'@xenova/transformers'
|
||||
'@xenova/transformers',
|
||||
'@xterm/xterm',
|
||||
'@xterm/addon-fit'
|
||||
],
|
||||
exclude: [
|
||||
// Exclude problematic modules from pre-bundling
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { AutoRouter, IRequest, error } from "itty-router"
|
||||
import throttle from "lodash.throttle"
|
||||
import { Environment } from "./types"
|
||||
import { AutomergeSyncManager } from "./automerge-sync-manager"
|
||||
|
||||
// each whiteboard room is hosted in a DurableObject:
|
||||
// https://developers.cloudflare.com/durable-objects/
|
||||
|
|
@ -29,6 +30,17 @@ export class AutomergeDurableObject {
|
|||
private cachedR2Doc: any = null
|
||||
// Store the Automerge document ID for this room
|
||||
private automergeDocumentId: string | null = null
|
||||
// CRDT Sync Manager - handles proper Automerge sync protocol
|
||||
private syncManager: AutomergeSyncManager | null = null
|
||||
// Flag to enable/disable CRDT sync (for gradual rollout)
|
||||
// ENABLED: Automerge WASM now works with fixed import path
|
||||
private useCrdtSync: boolean = true
|
||||
// Tombstone tracking - keeps track of deleted shape IDs to prevent resurrection
|
||||
// When a shape is deleted, its ID is added here and persisted to R2
|
||||
// This prevents offline clients from resurrecting deleted shapes
|
||||
private deletedShapeIds: Set<string> = new Set()
|
||||
// Flag to track if tombstones have been loaded from R2
|
||||
private tombstonesLoaded: boolean = false
|
||||
|
||||
constructor(private readonly ctx: DurableObjectState, env: Environment) {
|
||||
this.r2 = env.TLDRAW_BUCKET
|
||||
|
|
@ -194,12 +206,28 @@ export class AutomergeDurableObject {
|
|||
// what happens when someone tries to connect to this room?
|
||||
async handleConnect(request: IRequest): Promise<Response> {
|
||||
console.log(`🔌 AutomergeDurableObject: Received connection request for room ${this.roomId}`)
|
||||
|
||||
console.log(`🔌 AutomergeDurableObject: CRDT state: useCrdtSync=${this.useCrdtSync}, hasSyncManager=${!!this.syncManager}`)
|
||||
|
||||
if (!this.roomId) {
|
||||
console.error(`❌ AutomergeDurableObject: Room not initialized`)
|
||||
return new Response("Room not initialized", { status: 400 })
|
||||
}
|
||||
|
||||
// Initialize CRDT sync manager if not already done
|
||||
if (this.useCrdtSync && !this.syncManager) {
|
||||
console.log(`🔧 Initializing CRDT sync manager for room ${this.roomId}`)
|
||||
this.syncManager = new AutomergeSyncManager(this.r2, this.roomId)
|
||||
try {
|
||||
await this.syncManager.initialize()
|
||||
console.log(`✅ CRDT sync manager initialized (${this.syncManager.getShapeCount()} shapes)`)
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to initialize CRDT sync manager:`, error)
|
||||
// Disable CRDT sync on initialization failure
|
||||
this.useCrdtSync = false
|
||||
this.syncManager = null
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = request.query.sessionId as string
|
||||
console.log(`🔌 AutomergeDurableObject: Session ID: ${sessionId}`)
|
||||
|
||||
|
|
@ -255,6 +283,10 @@ export class AutomergeDurableObject {
|
|||
serverWebSocket.addEventListener("close", (event) => {
|
||||
console.log(`🔌 AutomergeDurableObject: Client disconnected: ${sessionId}, code: ${event.code}, reason: ${event.reason}`)
|
||||
this.clients.delete(sessionId)
|
||||
// Clean up sync manager state for this peer
|
||||
if (this.syncManager) {
|
||||
this.syncManager.handlePeerDisconnect(sessionId)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle WebSocket errors
|
||||
|
|
@ -298,8 +330,21 @@ export class AutomergeDurableObject {
|
|||
wasConvertedFromOldFormat: this.wasConvertedFromOldFormat
|
||||
})
|
||||
|
||||
// Automerge sync protocol will handle loading the document
|
||||
// No JSON sync needed - everything goes through Automerge's native sync
|
||||
// CRITICAL: Send initial sync message to client to bring them up to date
|
||||
// This kicks off the Automerge sync protocol
|
||||
if (this.useCrdtSync && this.syncManager) {
|
||||
try {
|
||||
const initialSyncMessage = await this.syncManager.generateSyncMessageForPeer(sessionId)
|
||||
if (initialSyncMessage) {
|
||||
serverWebSocket.send(initialSyncMessage)
|
||||
console.log(`📤 Sent initial CRDT sync message to ${sessionId}: ${initialSyncMessage.byteLength} bytes`)
|
||||
} else {
|
||||
console.log(`ℹ️ No initial sync message needed for ${sessionId} (client may be up to date)`)
|
||||
}
|
||||
} catch (syncError) {
|
||||
console.error(`❌ Error sending initial sync message to ${sessionId}:`, syncError)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ AutomergeDurableObject: Error sending document to client ${sessionId}:`, error)
|
||||
console.error(`❌ AutomergeDurableObject: Error stack:`, error instanceof Error ? error.stack : 'No stack trace')
|
||||
|
|
@ -427,13 +472,56 @@ export class AutomergeDurableObject {
|
|||
private async handleBinaryMessage(sessionId: string, data: ArrayBuffer) {
|
||||
// Handle incoming binary Automerge sync data from client
|
||||
console.log(`🔌 Worker: Handling binary sync message from ${sessionId}, size: ${data.byteLength} bytes`)
|
||||
|
||||
// Broadcast binary data directly to other clients for Automerge's native sync protocol
|
||||
// Automerge Repo handles the binary sync protocol internally
|
||||
this.broadcastBinaryToOthers(sessionId, data)
|
||||
|
||||
// NOTE: Clients will periodically POST their document state to /room/:roomId
|
||||
// which updates this.currentDoc and triggers persistence to R2
|
||||
|
||||
// Check if CRDT sync is enabled
|
||||
if (this.useCrdtSync && this.syncManager) {
|
||||
try {
|
||||
// CRITICAL: Use proper CRDT sync protocol
|
||||
// This ensures deletions and concurrent edits are merged correctly
|
||||
const uint8Data = new Uint8Array(data)
|
||||
const response = await this.syncManager.receiveSyncMessage(sessionId, uint8Data)
|
||||
|
||||
// Send response back to the client (if any)
|
||||
if (response) {
|
||||
const client = this.clients.get(sessionId)
|
||||
if (client && client.readyState === WebSocket.OPEN) {
|
||||
client.send(response)
|
||||
console.log(`📤 Sent sync response to ${sessionId}: ${response.byteLength} bytes`)
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast changes to other connected clients
|
||||
const broadcastMessages = await this.syncManager.generateBroadcastMessages(sessionId)
|
||||
for (const [peerId, message] of broadcastMessages) {
|
||||
const client = this.clients.get(peerId)
|
||||
if (client && client.readyState === WebSocket.OPEN) {
|
||||
client.send(message)
|
||||
console.log(`📤 Broadcast sync to ${peerId}: ${message.byteLength} bytes`)
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Keep currentDoc in sync with the CRDT document
|
||||
// This ensures HTTP endpoints and other code paths see the latest state
|
||||
const crdtDoc = await this.syncManager.getDocumentJson()
|
||||
if (crdtDoc) {
|
||||
this.currentDoc = crdtDoc
|
||||
// Clear R2 cache since document has been updated via CRDT
|
||||
this.cachedR2Doc = null
|
||||
this.cachedR2Hash = null
|
||||
}
|
||||
|
||||
console.log(`✅ CRDT sync processed for ${sessionId} (${this.syncManager.getShapeCount()} shapes)`)
|
||||
} catch (error) {
|
||||
console.error(`❌ CRDT sync error for ${sessionId}:`, error)
|
||||
// Fall back to relay mode on error
|
||||
this.broadcastBinaryToOthers(sessionId, data)
|
||||
}
|
||||
} else {
|
||||
// Legacy mode: Broadcast binary data directly to other clients
|
||||
// This is the old behavior that doesn't handle CRDT properly
|
||||
console.log(`⚠️ Using legacy relay mode (CRDT sync disabled)`)
|
||||
this.broadcastBinaryToOthers(sessionId, data)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSyncMessage(sessionId: string, message: any) {
|
||||
|
|
@ -588,10 +676,25 @@ export class AutomergeDurableObject {
|
|||
async getDocument() {
|
||||
if (!this.roomId) throw new Error("Missing roomId")
|
||||
|
||||
// CRITICAL: Always load from R2 first if we haven't loaded yet
|
||||
// Don't return currentDoc if it was set by a client POST before R2 load
|
||||
// This ensures we get all shapes from R2, not just what the client sent
|
||||
|
||||
// CRDT MODE: If sync manager is active, return its document
|
||||
// This ensures HTTP endpoints return the authoritative CRDT state
|
||||
if (this.useCrdtSync && this.syncManager) {
|
||||
try {
|
||||
const crdtDoc = await this.syncManager.getDocumentJson()
|
||||
if (crdtDoc && crdtDoc.store && Object.keys(crdtDoc.store).length > 0) {
|
||||
const shapeCount = Object.values(crdtDoc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
console.log(`📥 getDocument: Returning CRDT document (${shapeCount} shapes)`)
|
||||
// Keep currentDoc in sync with CRDT state
|
||||
this.currentDoc = crdtDoc
|
||||
return crdtDoc
|
||||
}
|
||||
console.log(`⚠️ getDocument: CRDT document is empty, falling back to R2`)
|
||||
} catch (error) {
|
||||
console.error(`❌ getDocument: Error getting CRDT document:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// FALLBACK: Load from R2 JSON if CRDT is not available
|
||||
// If R2 load is in progress or completed, wait for it and return the result
|
||||
if (this.roomPromise) {
|
||||
const doc = await this.roomPromise
|
||||
|
|
@ -705,7 +808,11 @@ export class AutomergeDurableObject {
|
|||
this.currentDoc = initialDoc
|
||||
// Store conversion flag for JSON sync decision
|
||||
this.wasConvertedFromOldFormat = wasConverted
|
||||
|
||||
|
||||
// Load tombstones to prevent resurrection of deleted shapes
|
||||
await this.loadTombstones()
|
||||
console.log(`🪦 Tombstone state after load: ${this.deletedShapeIds.size} tombstones for room ${this.roomId}`)
|
||||
|
||||
// Initialize the last persisted hash with the loaded document
|
||||
this.lastPersistedHash = this.generateDocHash(initialDoc)
|
||||
|
||||
|
|
@ -1058,47 +1165,75 @@ export class AutomergeDurableObject {
|
|||
console.warn(`⚠️ R2 load failed, continuing with client update:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TOMBSTONE HANDLING: Load tombstones if not yet loaded
|
||||
if (!this.tombstonesLoaded) {
|
||||
await this.loadTombstones()
|
||||
}
|
||||
|
||||
// Filter out tombstoned shapes from incoming document to prevent resurrection
|
||||
let processedNewStore = newDoc?.store || {}
|
||||
if (newDoc?.store && this.deletedShapeIds.size > 0) {
|
||||
const { filteredStore, removedCount } = this.filterTombstonedShapes(newDoc.store)
|
||||
if (removedCount > 0) {
|
||||
console.log(`🪦 Filtered ${removedCount} tombstoned shapes from incoming update (preventing resurrection)`)
|
||||
processedNewStore = filteredStore
|
||||
}
|
||||
}
|
||||
|
||||
const oldShapeCount = this.currentDoc?.store ? Object.values(this.currentDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
const newShapeCount = newDoc?.store ? Object.values(newDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
|
||||
const newShapeCount = processedNewStore ? Object.values(processedNewStore).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
|
||||
// Get list of old shape IDs to check if we're losing any
|
||||
const oldShapeIds = this.currentDoc?.store ?
|
||||
const oldShapeIds = this.currentDoc?.store ?
|
||||
Object.values(this.currentDoc.store)
|
||||
.filter((r: any) => r?.typeName === 'shape')
|
||||
.map((r: any) => r.id) : []
|
||||
const newShapeIds = newDoc?.store ?
|
||||
Object.values(newDoc.store)
|
||||
const newShapeIds = processedNewStore ?
|
||||
Object.values(processedNewStore)
|
||||
.filter((r: any) => r?.typeName === 'shape')
|
||||
.map((r: any) => r.id) : []
|
||||
|
||||
// CRITICAL: Replace the entire store with the client's document
|
||||
|
||||
// TOMBSTONE HANDLING: Detect deletions from current doc
|
||||
// Shapes in current doc that aren't in the incoming doc are being deleted
|
||||
let newDeletions = 0
|
||||
if (this.currentDoc?.store && processedNewStore) {
|
||||
newDeletions = this.detectDeletions(this.currentDoc.store, processedNewStore)
|
||||
if (newDeletions > 0) {
|
||||
console.log(`🪦 Detected ${newDeletions} new shape deletions, saving tombstones`)
|
||||
// Save tombstones immediately to persist deletion tracking
|
||||
await this.saveTombstones()
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Replace the entire store with the processed client document
|
||||
// The client's document is authoritative and includes deletions
|
||||
// This ensures that when shapes are deleted, they're actually removed
|
||||
// Tombstoned shapes have already been filtered out to prevent resurrection
|
||||
// Clear R2 cache since document has been updated
|
||||
this.cachedR2Doc = null
|
||||
this.cachedR2Hash = null
|
||||
|
||||
if (this.currentDoc && newDoc?.store) {
|
||||
|
||||
if (this.currentDoc && processedNewStore) {
|
||||
// Count records before update
|
||||
const recordsBefore = Object.keys(this.currentDoc.store || {}).length
|
||||
|
||||
// Replace the entire store with the client's version (preserves deletions)
|
||||
this.currentDoc.store = { ...newDoc.store }
|
||||
|
||||
|
||||
// Replace the entire store with the processed client's version
|
||||
this.currentDoc.store = { ...processedNewStore }
|
||||
|
||||
// Count records after update
|
||||
const recordsAfter = Object.keys(this.currentDoc.store).length
|
||||
|
||||
|
||||
// Update schema if provided
|
||||
if (newDoc.schema) {
|
||||
this.currentDoc.schema = newDoc.schema
|
||||
}
|
||||
|
||||
console.log(`📊 updateDocument: Replaced store with client document: ${recordsBefore} -> ${recordsAfter} records (client sent ${Object.keys(newDoc.store).length})`)
|
||||
|
||||
console.log(`📊 updateDocument: Replaced store with client document: ${recordsBefore} -> ${recordsAfter} records (client sent ${Object.keys(newDoc.store || {}).length}, after tombstone filter: ${Object.keys(processedNewStore).length})`)
|
||||
} else {
|
||||
// If no current doc yet, set it (R2 load should have completed by now)
|
||||
console.log(`📊 updateDocument: No current doc, setting to new doc (${newShapeCount} shapes)`)
|
||||
this.currentDoc = newDoc
|
||||
// Use processed store which has tombstoned shapes filtered out
|
||||
console.log(`📊 updateDocument: No current doc, setting to new doc (${newShapeCount} shapes after tombstone filter)`)
|
||||
this.currentDoc = { ...newDoc, store: processedNewStore }
|
||||
}
|
||||
|
||||
const finalShapeCount = this.currentDoc?.store ? Object.values(this.currentDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
|
|
@ -1107,10 +1242,15 @@ export class AutomergeDurableObject {
|
|||
.filter((r: any) => r?.typeName === 'shape')
|
||||
.map((r: any) => r.id) : []
|
||||
|
||||
// Check for lost shapes
|
||||
const lostShapes = oldShapeIds.filter(id => !finalShapeIds.includes(id))
|
||||
// Check for lost shapes (excluding intentional deletions tracked as tombstones)
|
||||
const lostShapes = oldShapeIds.filter(id => !finalShapeIds.includes(id) && !this.deletedShapeIds.has(id))
|
||||
if (lostShapes.length > 0) {
|
||||
console.error(`❌ CRITICAL: Lost ${lostShapes.length} shapes during merge! Lost IDs:`, lostShapes)
|
||||
console.error(`❌ CRITICAL: Lost ${lostShapes.length} shapes during merge (not tracked as deletions)! Lost IDs:`, lostShapes)
|
||||
}
|
||||
// Log intentional deletions separately (for debugging)
|
||||
const intentionallyDeleted = oldShapeIds.filter(id => !finalShapeIds.includes(id) && this.deletedShapeIds.has(id))
|
||||
if (intentionallyDeleted.length > 0) {
|
||||
console.log(`🪦 ${intentionallyDeleted.length} shapes intentionally deleted (tracked as tombstones)`)
|
||||
}
|
||||
|
||||
if (finalShapeCount !== oldShapeCount) {
|
||||
|
|
@ -1667,7 +1807,21 @@ export class AutomergeDurableObject {
|
|||
// we throttle persistence so it only happens every 2 seconds, batching all updates
|
||||
schedulePersistToR2 = throttle(async () => {
|
||||
console.log(`📤 schedulePersistToR2 called for room ${this.roomId}`)
|
||||
|
||||
|
||||
// CRDT MODE: Sync manager handles all persistence to automerge.bin
|
||||
// Skip JSON persistence when CRDT is active to avoid dual storage
|
||||
if (this.useCrdtSync && this.syncManager) {
|
||||
console.log(`📤 CRDT mode active - sync manager handles persistence to automerge.bin`)
|
||||
// Force sync manager to save immediately
|
||||
try {
|
||||
await this.syncManager.forceSave()
|
||||
console.log(`✅ CRDT document saved via sync manager`)
|
||||
} catch (error) {
|
||||
console.error(`❌ Error saving CRDT document:`, error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.roomId || !this.currentDoc) {
|
||||
console.log(`⚠️ Cannot persist to R2: roomId=${this.roomId}, currentDoc=${!!this.currentDoc}`)
|
||||
return
|
||||
|
|
@ -1846,4 +2000,124 @@ export class AutomergeDurableObject {
|
|||
// Clients should periodically send their document state, so this is mainly for logging
|
||||
console.log(`📡 Worker: Document state requested from ${sessionId} (clients should send via POST /room/:roomId)`)
|
||||
}
|
||||
|
||||
// ==================== TOMBSTONE MANAGEMENT ====================
|
||||
// These methods handle tracking deleted shapes to prevent resurrection
|
||||
// when offline clients reconnect with stale data
|
||||
|
||||
/**
|
||||
* Load tombstones from R2 storage
|
||||
* Called during initialization to restore deleted shape tracking
|
||||
*/
|
||||
private async loadTombstones(): Promise<void> {
|
||||
if (this.tombstonesLoaded || !this.roomId) return
|
||||
|
||||
try {
|
||||
const tombstoneKey = `rooms/${this.roomId}/tombstones.json`
|
||||
const object = await this.r2.get(tombstoneKey)
|
||||
|
||||
if (object) {
|
||||
const data = await object.json() as { deletedShapeIds: string[], lastUpdated: string }
|
||||
this.deletedShapeIds = new Set(data.deletedShapeIds || [])
|
||||
console.log(`🪦 Loaded ${this.deletedShapeIds.size} tombstones for room ${this.roomId}`)
|
||||
} else {
|
||||
console.log(`🪦 No tombstones found for room ${this.roomId}, starting fresh`)
|
||||
this.deletedShapeIds = new Set()
|
||||
}
|
||||
|
||||
this.tombstonesLoaded = true
|
||||
} catch (error) {
|
||||
console.error(`❌ Error loading tombstones for room ${this.roomId}:`, error)
|
||||
this.deletedShapeIds = new Set()
|
||||
this.tombstonesLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save tombstones to R2 storage
|
||||
* Called after detecting deletions to persist the tombstone list
|
||||
*/
|
||||
private async saveTombstones(): Promise<void> {
|
||||
if (!this.roomId) return
|
||||
|
||||
try {
|
||||
const tombstoneKey = `rooms/${this.roomId}/tombstones.json`
|
||||
const data = {
|
||||
deletedShapeIds: Array.from(this.deletedShapeIds),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
count: this.deletedShapeIds.size
|
||||
}
|
||||
|
||||
await this.r2.put(tombstoneKey, JSON.stringify(data), {
|
||||
httpMetadata: { contentType: 'application/json' }
|
||||
})
|
||||
|
||||
console.log(`🪦 Saved ${this.deletedShapeIds.size} tombstones for room ${this.roomId}`)
|
||||
} catch (error) {
|
||||
console.error(`❌ Error saving tombstones for room ${this.roomId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect deleted shapes by comparing old and new stores
|
||||
* Adds newly deleted shape IDs to the tombstone set
|
||||
* @returns Number of new deletions detected
|
||||
*/
|
||||
private detectDeletions(oldStore: Record<string, any>, newStore: Record<string, any>): number {
|
||||
let newDeletions = 0
|
||||
|
||||
// Find shapes that existed in oldStore but not in newStore
|
||||
for (const id of Object.keys(oldStore)) {
|
||||
const record = oldStore[id]
|
||||
// Only track shape deletions (not camera, instance, etc.)
|
||||
if (record?.typeName === 'shape' && !newStore[id]) {
|
||||
if (!this.deletedShapeIds.has(id)) {
|
||||
this.deletedShapeIds.add(id)
|
||||
newDeletions++
|
||||
console.log(`🪦 Detected deletion of shape: ${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newDeletions
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out tombstoned shapes from a store
|
||||
* Prevents resurrection of deleted shapes
|
||||
* @returns Filtered store and count of shapes removed
|
||||
*/
|
||||
private filterTombstonedShapes(store: Record<string, any>): {
|
||||
filteredStore: Record<string, any>,
|
||||
removedCount: number
|
||||
} {
|
||||
const filteredStore: Record<string, any> = {}
|
||||
let removedCount = 0
|
||||
|
||||
for (const [id, record] of Object.entries(store)) {
|
||||
// Check if this is a tombstoned shape
|
||||
if (record?.typeName === 'shape' && this.deletedShapeIds.has(id)) {
|
||||
removedCount++
|
||||
console.log(`🪦 Blocking resurrection of tombstoned shape: ${id}`)
|
||||
} else {
|
||||
filteredStore[id] = record
|
||||
}
|
||||
}
|
||||
|
||||
return { filteredStore, removedCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old tombstones that are older than the retention period
|
||||
* Called periodically to prevent unbounded tombstone growth
|
||||
* For now, we keep all tombstones - can add expiry logic later
|
||||
*/
|
||||
private cleanupOldTombstones(): void {
|
||||
// TODO: Implement tombstone expiry if needed
|
||||
// For now, tombstones are permanent to ensure deleted shapes never return
|
||||
// This is the safest approach for collaborative editing
|
||||
if (this.deletedShapeIds.size > 10000) {
|
||||
console.warn(`⚠️ Large tombstone count (${this.deletedShapeIds.size}) for room ${this.roomId}. Consider implementing expiry.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Automerge WASM initialization for Cloudflare Workers
|
||||
*
|
||||
* This module handles the proper initialization of Automerge's WASM module
|
||||
* in a Cloudflare Workers environment.
|
||||
*
|
||||
* @see https://automerge.org/docs/reference/library-initialization/
|
||||
* @see https://automerge.org/blog/2024/08/23/wasm-packaging/
|
||||
*/
|
||||
|
||||
// Import from the slim variant for manual WASM initialization
|
||||
import * as Automerge from '@automerge/automerge/slim'
|
||||
|
||||
// Import the WASM binary using Wrangler's module bundling
|
||||
// The ?module suffix tells Wrangler to bundle this as a WebAssembly module
|
||||
// CRITICAL: Use relative path from worker/ directory to node_modules to fix Wrangler resolution
|
||||
import automergeWasm from '../node_modules/@automerge/automerge/dist/automerge.wasm?module'
|
||||
|
||||
let isInitialized = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Initialize Automerge WASM module
|
||||
* This must be called before using any Automerge functions
|
||||
* Safe to call multiple times - will only initialize once
|
||||
*/
|
||||
export async function initializeAutomerge(): Promise<void> {
|
||||
if (isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
if (initPromise) {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
console.log('🔧 Initializing Automerge WASM...')
|
||||
|
||||
// Initialize with the WASM module
|
||||
// In Cloudflare Workers, we pass the WebAssembly module directly
|
||||
await Automerge.initializeWasm(automergeWasm)
|
||||
|
||||
isInitialized = true
|
||||
console.log('✅ Automerge WASM initialized successfully')
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize Automerge WASM:', error)
|
||||
initPromise = null
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Automerge is initialized
|
||||
*/
|
||||
export function isAutomergeInitialized(): boolean {
|
||||
return isInitialized
|
||||
}
|
||||
|
||||
// Re-export Automerge for convenience
|
||||
export { Automerge }
|
||||
|
||||
// Export commonly used types
|
||||
export type { Doc, Patch, SyncState } from '@automerge/automerge/slim'
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* R2 Storage Adapter for Automerge Documents
|
||||
*
|
||||
* Stores Automerge documents as binary in R2, with support for:
|
||||
* - Binary document storage (not JSON)
|
||||
* - Chunking for large documents (R2 supports up to 5GB per object)
|
||||
* - Atomic updates
|
||||
*
|
||||
* Document storage format in R2:
|
||||
* - rooms/{roomId}/automerge.bin - The Automerge document binary
|
||||
* - rooms/{roomId}/metadata.json - Optional metadata (schema version, etc.)
|
||||
*/
|
||||
|
||||
import { Automerge, initializeAutomerge } from './automerge-init'
|
||||
|
||||
// TLDraw store snapshot type (simplified - actual type is more complex)
|
||||
export interface TLStoreSnapshot {
|
||||
store: Record<string, any>
|
||||
schema?: {
|
||||
schemaVersion: number
|
||||
storeVersion: number
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* R2 Storage for Automerge Documents
|
||||
*/
|
||||
export class AutomergeR2Storage {
|
||||
constructor(private r2: R2Bucket) {}
|
||||
|
||||
/**
|
||||
* Load an Automerge document from R2
|
||||
* Returns null if document doesn't exist
|
||||
*/
|
||||
async loadDocument(roomId: string): Promise<Automerge.Doc<TLStoreSnapshot> | null> {
|
||||
await initializeAutomerge()
|
||||
|
||||
const key = this.getDocumentKey(roomId)
|
||||
console.log(`📥 Loading Automerge document from R2: ${key}`)
|
||||
|
||||
try {
|
||||
const object = await this.r2.get(key)
|
||||
|
||||
if (!object) {
|
||||
console.log(`📥 No Automerge document found in R2 for room ${roomId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const binary = await object.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(binary)
|
||||
|
||||
console.log(`📥 Loaded Automerge binary from R2: ${uint8Array.byteLength} bytes`)
|
||||
|
||||
// Load the Automerge document from binary
|
||||
const doc = Automerge.load<TLStoreSnapshot>(uint8Array)
|
||||
|
||||
const shapeCount = doc.store ?
|
||||
Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
const recordCount = doc.store ? Object.keys(doc.store).length : 0
|
||||
|
||||
console.log(`📥 Loaded Automerge document: ${recordCount} records, ${shapeCount} shapes`)
|
||||
|
||||
return doc
|
||||
} catch (error) {
|
||||
console.error(`❌ Error loading Automerge document from R2:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an Automerge document to R2
|
||||
*/
|
||||
async saveDocument(roomId: string, doc: Automerge.Doc<TLStoreSnapshot>): Promise<boolean> {
|
||||
await initializeAutomerge()
|
||||
|
||||
const key = this.getDocumentKey(roomId)
|
||||
|
||||
try {
|
||||
// Serialize the Automerge document to binary
|
||||
const binary = Automerge.save(doc)
|
||||
|
||||
const shapeCount = doc.store ?
|
||||
Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
const recordCount = doc.store ? Object.keys(doc.store).length : 0
|
||||
|
||||
console.log(`💾 Saving Automerge document to R2: ${key}`)
|
||||
console.log(`💾 Document stats: ${recordCount} records, ${shapeCount} shapes, ${binary.byteLength} bytes`)
|
||||
|
||||
// Save to R2
|
||||
await this.r2.put(key, binary, {
|
||||
httpMetadata: {
|
||||
contentType: 'application/octet-stream'
|
||||
},
|
||||
customMetadata: {
|
||||
format: 'automerge-binary',
|
||||
version: '1',
|
||||
recordCount: recordCount.toString(),
|
||||
shapeCount: shapeCount.toString(),
|
||||
savedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`✅ Successfully saved Automerge document to R2`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`❌ Error saving Automerge document to R2:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an Automerge document exists in R2
|
||||
*/
|
||||
async documentExists(roomId: string): Promise<boolean> {
|
||||
const key = this.getDocumentKey(roomId)
|
||||
const object = await this.r2.head(key)
|
||||
return object !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an Automerge document from R2
|
||||
*/
|
||||
async deleteDocument(roomId: string): Promise<boolean> {
|
||||
const key = this.getDocumentKey(roomId)
|
||||
try {
|
||||
await this.r2.delete(key)
|
||||
console.log(`🗑️ Deleted Automerge document from R2: ${key}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`❌ Error deleting Automerge document from R2:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a JSON document to Automerge format
|
||||
* Used for upgrading existing rooms from JSON to Automerge
|
||||
*/
|
||||
async migrateFromJson(roomId: string, jsonDoc: TLStoreSnapshot): Promise<Automerge.Doc<TLStoreSnapshot> | null> {
|
||||
await initializeAutomerge()
|
||||
|
||||
console.log(`🔄 Migrating room ${roomId} from JSON to Automerge format`)
|
||||
|
||||
try {
|
||||
// Create a new Automerge document
|
||||
let doc = Automerge.init<TLStoreSnapshot>()
|
||||
|
||||
// Apply the JSON data as a change
|
||||
doc = Automerge.change(doc, 'Migrate from JSON', (d) => {
|
||||
d.store = jsonDoc.store || {}
|
||||
if (jsonDoc.schema) {
|
||||
d.schema = jsonDoc.schema
|
||||
}
|
||||
})
|
||||
|
||||
// Save to R2
|
||||
const saved = await this.saveDocument(roomId, doc)
|
||||
if (!saved) {
|
||||
throw new Error('Failed to save migrated document')
|
||||
}
|
||||
|
||||
console.log(`✅ Successfully migrated room ${roomId} to Automerge format`)
|
||||
return doc
|
||||
} catch (error) {
|
||||
console.error(`❌ Error migrating room ${roomId} to Automerge:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a document is in Automerge format
|
||||
* (vs old JSON format)
|
||||
*/
|
||||
async isAutomergeFormat(roomId: string): Promise<boolean> {
|
||||
const key = this.getDocumentKey(roomId)
|
||||
const object = await this.r2.head(key)
|
||||
|
||||
if (!object) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check custom metadata for format marker
|
||||
const format = object.customMetadata?.format
|
||||
return format === 'automerge-binary'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the R2 key for a room's Automerge document
|
||||
*/
|
||||
private getDocumentKey(roomId: string): string {
|
||||
return `rooms/${roomId}/automerge.bin`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the R2 key for a room's legacy JSON document
|
||||
*/
|
||||
getLegacyJsonKey(roomId: string): string {
|
||||
return `rooms/${roomId}`
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
/**
|
||||
* Automerge CRDT Sync Manager
|
||||
*
|
||||
* This is the core component that implements proper CRDT sync semantics:
|
||||
* - Maintains the authoritative Automerge document on the server
|
||||
* - Tracks sync states per connected peer
|
||||
* - Processes incoming sync messages with proper CRDT merge
|
||||
* - Generates outgoing sync messages (only deltas, not full documents)
|
||||
* - Ensures deletions are preserved across offline/reconnect scenarios
|
||||
*
|
||||
* @see https://automerge.org/docs/cookbook/real-time/
|
||||
*/
|
||||
|
||||
import { Automerge, initializeAutomerge } from './automerge-init'
|
||||
import { AutomergeR2Storage, TLStoreSnapshot } from './automerge-r2-storage'
|
||||
|
||||
interface SyncPeerState {
|
||||
syncState: Automerge.SyncState
|
||||
lastActivity: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Automerge CRDT sync for a single room
|
||||
*/
|
||||
export class AutomergeSyncManager {
|
||||
private doc: Automerge.Doc<TLStoreSnapshot> | null = null
|
||||
private peerSyncStates: Map<string, SyncPeerState> = new Map()
|
||||
private storage: AutomergeR2Storage
|
||||
private roomId: string
|
||||
private isInitialized: boolean = false
|
||||
private initPromise: Promise<void> | null = null
|
||||
private pendingSave: boolean = false
|
||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Throttle saves to avoid excessive R2 writes
|
||||
private readonly SAVE_DEBOUNCE_MS = 2000
|
||||
|
||||
constructor(r2: R2Bucket, roomId: string) {
|
||||
this.storage = new AutomergeR2Storage(r2)
|
||||
this.roomId = roomId
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the sync manager
|
||||
* Loads document from R2 or creates new one
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.initPromise) {
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
this.initPromise = this._initialize()
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
private async _initialize(): Promise<void> {
|
||||
await initializeAutomerge()
|
||||
|
||||
console.log(`🔧 Initializing AutomergeSyncManager for room ${this.roomId}`)
|
||||
|
||||
// Try to load existing document from R2
|
||||
let doc = await this.storage.loadDocument(this.roomId)
|
||||
|
||||
if (!doc) {
|
||||
// Check if there's a legacy JSON document to migrate
|
||||
const legacyDoc = await this.loadLegacyJsonDocument()
|
||||
if (legacyDoc) {
|
||||
console.log(`🔄 Found legacy JSON document, migrating to Automerge format`)
|
||||
doc = await this.storage.migrateFromJson(this.roomId, legacyDoc)
|
||||
}
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
// Create new empty document
|
||||
console.log(`📝 Creating new Automerge document for room ${this.roomId}`)
|
||||
doc = Automerge.init<TLStoreSnapshot>()
|
||||
doc = Automerge.change(doc, 'Initialize empty store', (d) => {
|
||||
d.store = {}
|
||||
})
|
||||
}
|
||||
|
||||
this.doc = doc
|
||||
this.isInitialized = true
|
||||
|
||||
const shapeCount = this.getShapeCount()
|
||||
console.log(`✅ AutomergeSyncManager initialized: ${shapeCount} shapes`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load legacy JSON document from R2
|
||||
* Used for migration from old format
|
||||
*/
|
||||
private async loadLegacyJsonDocument(): Promise<TLStoreSnapshot | null> {
|
||||
try {
|
||||
const key = this.storage.getLegacyJsonKey(this.roomId)
|
||||
const object = await (this.storage as any).r2?.get(key)
|
||||
if (object) {
|
||||
const json = await object.json()
|
||||
if (json?.store) {
|
||||
return json as TLStoreSnapshot
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.log(`No legacy JSON document found for room ${this.roomId}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming binary sync message from a peer
|
||||
* This is the core CRDT merge operation
|
||||
*
|
||||
* @returns Response message to send back to the peer (or null if no response needed)
|
||||
*/
|
||||
async receiveSyncMessage(peerId: string, message: Uint8Array): Promise<Uint8Array | null> {
|
||||
await this.initialize()
|
||||
|
||||
if (!this.doc) {
|
||||
throw new Error('Document not initialized')
|
||||
}
|
||||
|
||||
// Get or create sync state for this peer
|
||||
let peerState = this.peerSyncStates.get(peerId)
|
||||
if (!peerState) {
|
||||
peerState = {
|
||||
syncState: Automerge.initSyncState(),
|
||||
lastActivity: Date.now()
|
||||
}
|
||||
this.peerSyncStates.set(peerId, peerState)
|
||||
console.log(`🤝 New peer connected: ${peerId}`)
|
||||
}
|
||||
peerState.lastActivity = Date.now()
|
||||
|
||||
const shapeCountBefore = this.getShapeCount()
|
||||
|
||||
try {
|
||||
// CRITICAL: This is where CRDT merge happens!
|
||||
// Automerge.receiveSyncMessage properly merges changes from the peer
|
||||
// including deletions (tracked as operations, not absence)
|
||||
const [newDoc, newSyncState, _patch] = Automerge.receiveSyncMessage(
|
||||
this.doc,
|
||||
peerState.syncState,
|
||||
message
|
||||
)
|
||||
|
||||
this.doc = newDoc
|
||||
peerState.syncState = newSyncState
|
||||
this.peerSyncStates.set(peerId, peerState)
|
||||
|
||||
const shapeCountAfter = this.getShapeCount()
|
||||
|
||||
if (shapeCountBefore !== shapeCountAfter) {
|
||||
console.log(`📊 Document changed: ${shapeCountBefore} → ${shapeCountAfter} shapes (peer: ${peerId})`)
|
||||
}
|
||||
|
||||
// Schedule save to R2 (debounced)
|
||||
this.scheduleSave()
|
||||
|
||||
// Generate response message (if we have changes to send back)
|
||||
const [nextSyncState, responseMessage] = Automerge.generateSyncMessage(
|
||||
this.doc,
|
||||
peerState.syncState
|
||||
)
|
||||
|
||||
if (responseMessage) {
|
||||
peerState.syncState = nextSyncState
|
||||
this.peerSyncStates.set(peerId, peerState)
|
||||
console.log(`📤 Sending sync response to ${peerId}: ${responseMessage.byteLength} bytes`)
|
||||
return responseMessage
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error(`❌ Error processing sync message from ${peerId}:`, error)
|
||||
// Reset sync state for this peer on error
|
||||
this.peerSyncStates.delete(peerId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate initial sync message for a newly connected peer
|
||||
* This sends our current document state to bring them up to date
|
||||
*/
|
||||
async generateSyncMessageForPeer(peerId: string): Promise<Uint8Array | null> {
|
||||
await this.initialize()
|
||||
|
||||
if (!this.doc) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get or create sync state for this peer
|
||||
let peerState = this.peerSyncStates.get(peerId)
|
||||
if (!peerState) {
|
||||
peerState = {
|
||||
syncState: Automerge.initSyncState(),
|
||||
lastActivity: Date.now()
|
||||
}
|
||||
this.peerSyncStates.set(peerId, peerState)
|
||||
}
|
||||
peerState.lastActivity = Date.now()
|
||||
|
||||
// Generate sync message
|
||||
const [nextSyncState, message] = Automerge.generateSyncMessage(
|
||||
this.doc,
|
||||
peerState.syncState
|
||||
)
|
||||
|
||||
if (message) {
|
||||
peerState.syncState = nextSyncState
|
||||
this.peerSyncStates.set(peerId, peerState)
|
||||
console.log(`📤 Generated initial sync message for ${peerId}: ${message.byteLength} bytes`)
|
||||
return message
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a local change to the document
|
||||
* Used when receiving JSON data from legacy clients
|
||||
*/
|
||||
async applyLocalChange(
|
||||
description: string,
|
||||
changeFn: (doc: TLStoreSnapshot) => void
|
||||
): Promise<void> {
|
||||
await this.initialize()
|
||||
|
||||
if (!this.doc) {
|
||||
throw new Error('Document not initialized')
|
||||
}
|
||||
|
||||
const shapeCountBefore = this.getShapeCount()
|
||||
|
||||
this.doc = Automerge.change(this.doc, description, changeFn)
|
||||
|
||||
const shapeCountAfter = this.getShapeCount()
|
||||
console.log(`📝 Applied local change: "${description}" (shapes: ${shapeCountBefore} → ${shapeCountAfter})`)
|
||||
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current document as JSON
|
||||
* Used for legacy compatibility and debugging
|
||||
*/
|
||||
async getDocumentJson(): Promise<TLStoreSnapshot | null> {
|
||||
await this.initialize()
|
||||
|
||||
if (!this.doc) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Convert Automerge document to plain JSON
|
||||
return JSON.parse(JSON.stringify(this.doc)) as TLStoreSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle peer disconnection
|
||||
* Clean up sync state but don't lose any data
|
||||
*/
|
||||
handlePeerDisconnect(peerId: string): void {
|
||||
if (this.peerSyncStates.has(peerId)) {
|
||||
this.peerSyncStates.delete(peerId)
|
||||
console.log(`👋 Peer disconnected: ${peerId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of shapes in the document
|
||||
*/
|
||||
getShapeCount(): number {
|
||||
if (!this.doc?.store) return 0
|
||||
return Object.values(this.doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of records in the document
|
||||
*/
|
||||
getRecordCount(): number {
|
||||
if (!this.doc?.store) return 0
|
||||
return Object.keys(this.doc.store).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a save to R2 (debounced)
|
||||
*/
|
||||
private scheduleSave(): void {
|
||||
this.pendingSave = true
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
}
|
||||
|
||||
this.saveTimeout = setTimeout(async () => {
|
||||
if (this.pendingSave && this.doc) {
|
||||
this.pendingSave = false
|
||||
await this.storage.saveDocument(this.roomId, this.doc)
|
||||
}
|
||||
}, this.SAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate save to R2
|
||||
* Call this before shutting down
|
||||
*/
|
||||
async forceSave(): Promise<void> {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
this.saveTimeout = null
|
||||
}
|
||||
|
||||
if (this.doc) {
|
||||
this.pendingSave = false
|
||||
await this.storage.saveDocument(this.roomId, this.doc)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast changes to all connected peers except the sender
|
||||
* Returns map of peerId -> sync message
|
||||
*/
|
||||
async generateBroadcastMessages(excludePeerId?: string): Promise<Map<string, Uint8Array>> {
|
||||
const messages = new Map<string, Uint8Array>()
|
||||
|
||||
for (const [peerId, peerState] of this.peerSyncStates) {
|
||||
if (peerId === excludePeerId) continue
|
||||
|
||||
const [nextSyncState, message] = Automerge.generateSyncMessage(
|
||||
this.doc!,
|
||||
peerState.syncState
|
||||
)
|
||||
|
||||
if (message) {
|
||||
peerState.syncState = nextSyncState
|
||||
this.peerSyncStates.set(peerId, peerState)
|
||||
messages.set(peerId, message)
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of connected peer IDs
|
||||
*/
|
||||
getConnectedPeers(): string[] {
|
||||
return Array.from(this.peerSyncStates.keys())
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale peer connections (inactive for > 5 minutes)
|
||||
*/
|
||||
cleanupStalePeers(): void {
|
||||
const STALE_THRESHOLD = 5 * 60 * 1000 // 5 minutes
|
||||
const now = Date.now()
|
||||
|
||||
for (const [peerId, peerState] of this.peerSyncStates) {
|
||||
if (now - peerState.lastActivity > STALE_THRESHOLD) {
|
||||
console.log(`🧹 Cleaning up stale peer: ${peerId}`)
|
||||
this.peerSyncStates.delete(peerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Type declarations for Wrangler's WASM module imports
|
||||
* The `?module` suffix tells Wrangler to bundle as a WebAssembly module
|
||||
*/
|
||||
|
||||
// Declaration for automerge WASM module
|
||||
declare module '@automerge/automerge/automerge.wasm?module' {
|
||||
const wasmModule: WebAssembly.Module
|
||||
export default wasmModule
|
||||
}
|
||||
|
||||
// Alternative path declaration
|
||||
declare module '@automerge/automerge/dist/automerge.wasm?module' {
|
||||
const wasmModule: WebAssembly.Module
|
||||
export default wasmModule
|
||||
}
|
||||
|
||||
// Workerd-specific WASM module
|
||||
declare module '@automerge/automerge/dist/mjs/wasm_bindgen_output/workerd/automerge_wasm_bg.wasm?module' {
|
||||
const wasmModule: WebAssembly.Module
|
||||
export default wasmModule
|
||||
}
|
||||
Loading…
Reference in New Issue