tangram-explore 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,173 @@
1
+ packages/tangram_*/src/tangram_*/package.json
2
+
3
+ # Configuration file
4
+ tangram.toml
5
+
6
+ dist-frontend
7
+ **/node_modules
8
+
9
+ ### Archive ###
10
+ archive/
11
+ *.ipynb
12
+ .git-old
13
+ .vscode/
14
+ .idea/
15
+
16
+ ### Data ###
17
+ data/
18
+
19
+ ### Python ###
20
+ # Byte-compiled / optimized / DLL files
21
+ __pycache__/
22
+ *.py[cod]
23
+ *$py.class
24
+
25
+ .DS_Store
26
+
27
+ # C extensions
28
+ *.so
29
+
30
+ # Distribution / packaging
31
+ .Python
32
+ build/
33
+ develop-eggs/
34
+ dist/
35
+ downloads/
36
+ eggs/
37
+ .eggs/
38
+ lib/
39
+ lib64/
40
+ parts/
41
+ sdist/
42
+ var/
43
+ wheels/
44
+ share/python-wheels/
45
+ *.egg-info/
46
+ .installed.cfg
47
+ *.egg
48
+ MANIFEST
49
+
50
+ # PyInstaller
51
+ # Usually these files are written by a python script from a template
52
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
53
+ *.manifest
54
+ *.spec
55
+
56
+ # Installer logs
57
+ pip-log.txt
58
+ pip-delete-this-directory.txt
59
+
60
+ # Unit test / coverage reports
61
+ htmlcov/
62
+ .tox/
63
+ .nox/
64
+ .coverage
65
+ .coverage.*
66
+ .cache
67
+ nosetests.xml
68
+ coverage.xml
69
+ *.cover
70
+ *.py,cover
71
+ .hypothesis/
72
+ .pytest_cache/
73
+ cover/
74
+
75
+ # Translations
76
+ *.mo
77
+ *.pot
78
+
79
+ # Django stuff:
80
+ *.log
81
+ local_settings.py
82
+ db.sqlite3
83
+ db.sqlite3-journal
84
+
85
+ # Flask stuff:
86
+ instance/
87
+ .webassets-cache
88
+
89
+ # Scrapy stuff:
90
+ .scrapy
91
+
92
+ # Sphinx documentation
93
+ docs/_build/
94
+
95
+ # PyBuilder
96
+ .pybuilder/
97
+ target/
98
+
99
+ # Jupyter Notebook
100
+ .ipynb_checkpoints
101
+
102
+ # IPython
103
+ profile_default/
104
+ ipython_config.py
105
+
106
+ # pyenv
107
+ # For a library or package, you might want to ignore these files since the code is
108
+ # intended to run in multiple environments; otherwise, check them in:
109
+ # .python-version
110
+
111
+ # pipenv
112
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
113
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
114
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
115
+ # install all needed dependencies.
116
+ #Pipfile.lock
117
+
118
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
119
+ __pypackages__/
120
+
121
+ # Celery stuff
122
+ celerybeat-schedule
123
+ celerybeat.pid
124
+
125
+ # SageMath parsed files
126
+ *.sage.py
127
+
128
+ # Environments
129
+ .env
130
+ .venv
131
+ .venv_container
132
+ .venv_whl
133
+ env/
134
+ venv/
135
+ ENV/
136
+ env.bak/
137
+ venv.bak/
138
+ .envrc
139
+ .direnv/
140
+
141
+ # Spyder project settings
142
+ .spyderproject
143
+ .spyproject
144
+
145
+ # Rope project settings
146
+ .ropeproject
147
+
148
+ # mkdocs documentation
149
+ /site
150
+
151
+ # mypy
152
+ .mypy_cache/
153
+ .dmypy.json
154
+ dmypy.json
155
+
156
+ # Pyre type checker
157
+ .pyre/
158
+
159
+ # pytype static type analyzer
160
+ .pytype/
161
+
162
+ # Cython debug symbols
163
+ cython_debug/
164
+
165
+ # nix
166
+ /result
167
+
168
+ # tangram plugins
169
+ *.sqlite3
170
+ *.sqlite3-journal
171
+ # End of https://www.toptal.com/developers/gitignore/api/python
172
+
173
+ .npm/
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: tangram-explore
3
+ Version: 0.3.0
4
+ Summary: Tangram plugin for exploring any Arrow-backed dataframe
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: arro3-core>=0.4.0
7
+ Requires-Dist: arro3-io>=0.4.0
8
+ Requires-Dist: tangram-core>=0.2.1
9
+ Provides-Extra: polars
10
+ Requires-Dist: polars>=1; extra == 'polars'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # tangram_explore
14
+
15
+ The `tangram_explore` plugin provides an interactive environment for data exploration and visualization within Tangram.
16
+
17
+ It provides:
18
+
19
+ - A Python API to generate interactive plots.
20
+ - Real-time communication with the Tangram frontend.
21
+ - Flexible layer-based visualization system.
@@ -0,0 +1,35 @@
1
+ import asyncio
2
+
3
+ import numpy as np
4
+ import polars as pl
5
+ import tangram_core
6
+ from tangram_core.config import CoreConfig
7
+ from tangram_explore import ScatterLayer, Session
8
+
9
+
10
+ async def main() -> None:
11
+ async with tangram_core.Runtime(
12
+ config=tangram_core.Config(core=CoreConfig(plugins=["tangram_explore"]))
13
+ ) as runtime:
14
+ session = Session(runtime.state)
15
+
16
+ x = np.linspace(-13, 13, 100)
17
+ await session.push(
18
+ ScatterLayer(
19
+ pl.DataFrame({"longitude": x, "latitude": x}),
20
+ fill_color="#027ec7",
21
+ label="NE-SW",
22
+ ),
23
+ )
24
+ await session.push(
25
+ ScatterLayer(
26
+ pl.DataFrame({"longitude": x, "latitude": -x}),
27
+ fill_color="#be4d5e",
28
+ label="NW-SE",
29
+ )
30
+ )
31
+ await runtime.wait()
32
+
33
+
34
+ if __name__ == "__main__":
35
+ asyncio.run(main())
@@ -0,0 +1,36 @@
1
+ # ruff: noqa: F704, E402
2
+ # %%
3
+ import numpy as np
4
+ import polars as pl
5
+ import tangram_core
6
+ from tangram_core.config import CoreConfig
7
+
8
+ runtime = tangram_core.Runtime(
9
+ config=tangram_core.Config(core=CoreConfig(plugins=["tangram_explore"]))
10
+ )
11
+ await runtime.start()
12
+ # %%
13
+ from tangram_explore import ScatterLayer, Session
14
+
15
+ session = Session(runtime.state)
16
+
17
+ x = np.linspace(-13, 13, 100)
18
+ # %%
19
+ await session.push(
20
+ ScatterLayer(
21
+ pl.DataFrame({"longitude": x, "latitude": x}),
22
+ fill_color="#027ec7",
23
+ label="NE-SW",
24
+ )
25
+ )
26
+ # %%
27
+ await session.push(
28
+ ScatterLayer(
29
+ pl.DataFrame({"longitude": x, "latitude": -x}),
30
+ fill_color="#be4d5e",
31
+ label="NW-SE",
32
+ )
33
+ )
34
+ # %%
35
+ await runtime.stop()
36
+ # %%
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@open-aviation/tangram-explore",
3
+ "version": "0.3.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Explore flights in history",
7
+ "main": "src/tangram_explore/index.ts",
8
+ "license": "MIT",
9
+ "scripts": {
10
+ "build": "vite build"
11
+ },
12
+ "dependencies": {
13
+ "@open-aviation/tangram-core": "workspace:*",
14
+ "apache-arrow": "^21.1.0",
15
+ "arrow-js-ffi": "^0.4.3"
16
+ },
17
+ "devDependencies": {
18
+ "@deck.gl/core": "^9.2.6",
19
+ "@deck.gl/extensions": "^9.2.6",
20
+ "@deck.gl/layers": "^9.2.6",
21
+ "parquet-wasm": "^0.7.1",
22
+ "vue": "^3.5.27"
23
+ }
24
+ }
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "tangram-explore"
3
+ version = "0.3.0"
4
+ description = "Tangram plugin for exploring any Arrow-backed dataframe"
5
+ readme = "readme.md"
6
+ requires-python = ">=3.10"
7
+ # pyarrow is the gold standard for parquet serialisation but its very huge, keeping it minimal
8
+ # we accept any object that adheres to the arrow C interface and thus do not require polars/pandas
9
+ dependencies = ["tangram_core>=0.2.1", "arro3-core>=0.4.0", "arro3-io>=0.4.0"]
10
+
11
+ [project.entry-points."tangram_core.plugins"]
12
+ tangram_explore = "tangram_explore:plugin"
13
+
14
+ [project.optional-dependencies]
15
+ polars = ["polars>=1"]
16
+
17
+ [tool.uv.sources]
18
+ tangram_core = { workspace = true }
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
@@ -0,0 +1,9 @@
1
+ # tangram_explore
2
+
3
+ The `tangram_explore` plugin provides an interactive environment for data exploration and visualization within Tangram.
4
+
5
+ It provides:
6
+
7
+ - A Python API to generate interactive plots.
8
+ - Real-time communication with the Tangram frontend.
9
+ - Flexible layer-based visualization system.
@@ -0,0 +1,172 @@
1
+ <script setup lang="ts">
2
+ import { watch, inject, onUnmounted, reactive, computed } from "vue";
3
+ import { ScatterplotLayer } from "@deck.gl/layers";
4
+ import type { PickingInfo } from "@deck.gl/core";
5
+ import type { TangramApi, Disposable } from "@open-aviation/tangram-core/api";
6
+ import { layers, parseColor, pluginConfig } from "./store";
7
+
8
+ const api = inject<TangramApi>("tangramApi")!;
9
+ const layerDisposables = new Map<string, Disposable>();
10
+
11
+ const hoverInfo = reactive({
12
+ x: 0,
13
+ y: 0,
14
+ object: null as Record<string, unknown> | null,
15
+ layerLabel: ""
16
+ });
17
+
18
+ const enable3d = computed(() => {
19
+ if (pluginConfig.enable_3d === "inherit") return api.config.map.enable_3d;
20
+ return !!pluginConfig.enable_3d;
21
+ });
22
+
23
+ watch(
24
+ [layers, enable3d],
25
+ ([currentLayers, is3d]) => {
26
+ const activeIds = new Set(currentLayers.map(l => l.id));
27
+
28
+ for (const [id, disposable] of layerDisposables) {
29
+ if (!activeIds.has(id)) {
30
+ disposable.dispose();
31
+ layerDisposables.delete(id);
32
+ }
33
+ }
34
+
35
+ for (const entry of currentLayers) {
36
+ const table = entry.table;
37
+ const numRows = table.numRows;
38
+ const schema = table.schema;
39
+
40
+ const latField = schema.fields.find(
41
+ f => f.name === "latitude" || f.name === "lat"
42
+ )?.name;
43
+ const lonField = schema.fields.find(
44
+ f => f.name === "longitude" || f.name === "lon" || f.name === "lng"
45
+ )?.name;
46
+
47
+ let altField: string | undefined;
48
+ if (is3d) {
49
+ altField = schema.fields.find(
50
+ f => f.name === "altitude" || f.name === "alt" || f.name === "height"
51
+ )?.name;
52
+ }
53
+
54
+ if (!latField || !lonField) continue;
55
+
56
+ const latData = table.getChild(latField)!;
57
+ const lonData = table.getChild(lonField)!;
58
+ const altData = altField ? table.getChild(altField) : null;
59
+
60
+ if (entry.style.kind === "scatter") {
61
+ const opts = entry.style;
62
+ const deckLayer = new ScatterplotLayer({
63
+ id: `explore-layer-${entry.id}`,
64
+ data: { length: numRows },
65
+ visible: entry.visible,
66
+ pickable: opts.pickable,
67
+ opacity: opts.opacity,
68
+ stroked: opts.stroked,
69
+ filled: opts.filled,
70
+ radiusScale: opts.radius_scale,
71
+ radiusMinPixels: opts.radius_min_pixels,
72
+ radiusMaxPixels: opts.radius_max_pixels,
73
+ lineWidthMinPixels: opts.line_width_min_pixels,
74
+ // radiusUnits: "pixels",
75
+ getPosition: (_: unknown, { index }: { index: number }) => {
76
+ const lat = latData.get(index);
77
+ const lon = lonData.get(index);
78
+ const alt = altData ? altData.get(index) : 0;
79
+ return [lon, lat, alt];
80
+ },
81
+ getFillColor: parseColor(opts.fill_color, [255, 140, 0, 200]),
82
+ getLineColor: parseColor(opts.line_color, [0, 0, 0, 255]),
83
+ onHover: (info: PickingInfo) => {
84
+ if (info.index !== -1) {
85
+ const row = table.get(info.index);
86
+ hoverInfo.object = row ? row.toJSON() : null;
87
+ hoverInfo.x = info.x;
88
+ hoverInfo.y = info.y;
89
+ hoverInfo.layerLabel = entry.label;
90
+ } else {
91
+ hoverInfo.object = null;
92
+ }
93
+ },
94
+ updateTriggers: {
95
+ getPosition: [entry.id, is3d],
96
+ getFillColor: [entry.id, JSON.stringify(opts.fill_color)],
97
+ getLineColor: [entry.id, JSON.stringify(opts.line_color)]
98
+ }
99
+ });
100
+
101
+ if (!layerDisposables.has(entry.id)) {
102
+ layerDisposables.set(entry.id, api.map.setLayer(deckLayer));
103
+ } else {
104
+ api.map.setLayer(deckLayer);
105
+ }
106
+ }
107
+ }
108
+ },
109
+ { deep: true }
110
+ );
111
+
112
+ onUnmounted(() => {
113
+ for (const disposable of layerDisposables.values()) {
114
+ disposable.dispose();
115
+ }
116
+ layerDisposables.clear();
117
+ });
118
+ </script>
119
+
120
+ <template>
121
+ <div
122
+ v-if="hoverInfo.object"
123
+ class="explore-tooltip"
124
+ :style="{ left: `${hoverInfo.x}px`, top: `${hoverInfo.y}px` }"
125
+ >
126
+ <div class="tooltip-header">{{ hoverInfo.layerLabel }}</div>
127
+ <div class="tooltip-grid">
128
+ <template v-for="(val, key) in hoverInfo.object" :key="key">
129
+ <div class="key">{{ key }}</div>
130
+ <div class="val">
131
+ {{ typeof val === "number" && !Number.isInteger(val) ? val.toFixed(4) : val }}
132
+ </div>
133
+ </template>
134
+ </div>
135
+ </div>
136
+ </template>
137
+
138
+ <style scoped>
139
+ .explore-tooltip {
140
+ position: absolute;
141
+ background: white;
142
+ color: black;
143
+ padding: 6px 10px;
144
+ border-radius: 8px;
145
+ font-size: 11px;
146
+ font-family: "B612", sans-serif;
147
+ pointer-events: none;
148
+ transform: translate(12px, 12px);
149
+ z-index: 2000;
150
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
151
+ border: 1px solid rgba(0, 0, 0, 0.05);
152
+ max-width: 250px;
153
+ }
154
+
155
+ .tooltip-header {
156
+ font-weight: bold;
157
+ padding-bottom: 4px;
158
+ color: #333;
159
+ }
160
+
161
+ .tooltip-grid {
162
+ display: grid;
163
+ grid-template-columns: auto auto;
164
+ column-gap: 12px;
165
+ row-gap: 1px;
166
+ }
167
+
168
+ .key {
169
+ text-align: right;
170
+ font-weight: 500;
171
+ }
172
+ </style>
@@ -0,0 +1,170 @@
1
+ <template>
2
+ <div class="layer-list">
3
+ <div v-if="layers.length === 0" class="empty-state">no active layers</div>
4
+ <div v-for="layer in layers" :key="layer.id" class="layer-item">
5
+ <div class="left-col">
6
+ <button
7
+ class="visibility-btn"
8
+ :title="layer.visible ? 'hide layer' : 'show layer'"
9
+ @click="toggleLayerVisibility(layer.id)"
10
+ >
11
+ <svg
12
+ v-if="layer.visible"
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ width="14"
15
+ height="14"
16
+ viewBox="0 0 24 24"
17
+ fill="none"
18
+ stroke="currentColor"
19
+ stroke-width="2"
20
+ stroke-linecap="round"
21
+ stroke-linejoin="round"
22
+ >
23
+ <path
24
+ d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
25
+ />
26
+ <circle cx="12" cy="12" r="3" />
27
+ </svg>
28
+ <svg
29
+ v-else
30
+ xmlns="http://www.w3.org/2000/svg"
31
+ width="14"
32
+ height="14"
33
+ viewBox="0 0 24 24"
34
+ fill="none"
35
+ stroke="currentColor"
36
+ stroke-width="2"
37
+ stroke-linecap="round"
38
+ stroke-linejoin="round"
39
+ >
40
+ <path d="m15 18-.722-3.25" />
41
+ <path d="M2 8a10.645 10.645 0 0 0 20 0" />
42
+ <path d="m20 15-1.726-2.05" />
43
+ <path d="m4 15 1.726-2.05" />
44
+ <path d="m9 18 .722-3.25" />
45
+ </svg>
46
+ </button>
47
+ <span class="layer-label">{{ layer.label }}</span>
48
+ </div>
49
+ <div class="right-col">
50
+ <span class="layer-stats">{{ layer.table.numRows.toLocaleString() }} rows</span>
51
+ <div class="layer-chip">
52
+ <div
53
+ class="status-dot"
54
+ :style="{ backgroundColor: getLayerColor(layer.style) }"
55
+ ></div>
56
+ <span class="kind-label">{{ layer.style.kind }}</span>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import { layers, toggleLayerVisibility, parseColor, type StyleOptions } from "./store";
65
+
66
+ function getLayerColor(style: StyleOptions): string {
67
+ let c;
68
+ if (style.kind === "scatter") {
69
+ c = style.fill_color;
70
+ } else {
71
+ c = [128, 128, 128];
72
+ }
73
+ const [r, g, b, a] = parseColor(c, [128, 128, 128, 255]);
74
+ return `rgba(${r}, ${g}, ${b}, ${a / 255})`;
75
+ }
76
+ </script>
77
+
78
+ <style scoped>
79
+ .layer-list {
80
+ display: flex;
81
+ flex-direction: column;
82
+ max-height: 300px;
83
+ overflow-y: auto;
84
+ }
85
+
86
+ .empty-state {
87
+ padding: 1rem;
88
+ text-align: center;
89
+ color: #666;
90
+ font-size: 0.9em;
91
+ font-style: italic;
92
+ }
93
+
94
+ .layer-item {
95
+ padding: 6px 12px;
96
+ border-bottom: 1px solid #eee;
97
+ display: flex;
98
+ justify-content: space-between;
99
+ align-items: center;
100
+ font-size: 0.9em;
101
+ }
102
+
103
+ .left-col {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 8px;
107
+ flex: 1;
108
+ min-width: 0;
109
+ }
110
+
111
+ .right-col {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 12px;
115
+ flex-shrink: 0;
116
+ }
117
+
118
+ .visibility-btn {
119
+ background: none;
120
+ border: none;
121
+ cursor: pointer;
122
+ padding: 2px;
123
+ color: #888;
124
+ display: flex;
125
+ align-items: center;
126
+ }
127
+
128
+ .visibility-btn:hover {
129
+ color: #333;
130
+ }
131
+
132
+ .layer-label {
133
+ font-weight: 500;
134
+ color: #333;
135
+ white-space: nowrap;
136
+ overflow: hidden;
137
+ text-overflow: ellipsis;
138
+ font-family: "B612", sans-serif;
139
+ }
140
+
141
+ .layer-chip {
142
+ display: flex;
143
+ align-items: center;
144
+ gap: 4px;
145
+ background-color: #f5f5f5;
146
+ padding: 2px 6px 2px 4px;
147
+ border-radius: 12px;
148
+ border: 1px solid #e0e0e0;
149
+ }
150
+
151
+ .status-dot {
152
+ width: 8px;
153
+ height: 8px;
154
+ border-radius: 50%;
155
+ }
156
+
157
+ .kind-label {
158
+ text-transform: lowercase;
159
+ font-size: 0.75em;
160
+ color: #555;
161
+ font-weight: 600;
162
+ line-height: 1;
163
+ }
164
+
165
+ .layer-stats {
166
+ color: #999;
167
+ font-variant-numeric: tabular-nums;
168
+ font-size: 0.85em;
169
+ }
170
+ </style>
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import uuid
5
+ from contextlib import asynccontextmanager
6
+ from dataclasses import KW_ONLY, dataclass, field, fields, is_dataclass
7
+ from typing import (
8
+ TYPE_CHECKING,
9
+ Annotated,
10
+ Any,
11
+ AsyncGenerator,
12
+ Literal,
13
+ Protocol,
14
+ cast,
15
+ runtime_checkable,
16
+ )
17
+
18
+ import arro3.core as ac
19
+ import arro3.io
20
+ import orjson
21
+ import tangram_core
22
+ from fastapi import APIRouter
23
+ from fastapi.responses import Response
24
+ from tangram_core.config import ExposeField
25
+
26
+ if TYPE_CHECKING:
27
+ from typing import TypeAlias
28
+
29
+ from tangram_core import BackendState
30
+
31
+ EXPLORE_CHANNEL = "explore"
32
+ EXPLORE_EVENT = "layers"
33
+
34
+ router = APIRouter(prefix="/explore", tags=["explore"])
35
+
36
+
37
+ LayerId: TypeAlias = str
38
+ """Unique UUID for a layer."""
39
+ ParquetBytes: TypeAlias = bytes
40
+ LayerConfig: TypeAlias = dict[str, Any]
41
+ """Serialised layer configuration without data or label."""
42
+
43
+
44
+ @dataclass
45
+ class ExploreState:
46
+ data: dict[LayerId, ParquetBytes] = field(default_factory=dict)
47
+ layers: dict[LayerId, LayerConfig] = field(default_factory=dict)
48
+ layer_order: list[LayerId] = field(default_factory=list)
49
+
50
+
51
+ @asynccontextmanager
52
+ async def lifespan(state: BackendState) -> AsyncGenerator[None, None]:
53
+ setattr(state, "explore_state", ExploreState())
54
+ yield
55
+ delattr(state, "explore_state")
56
+
57
+
58
+ def get_explore_state(state: tangram_core.InjectBackendState) -> ExploreState:
59
+ return cast(ExploreState, getattr(state, "explore_state"))
60
+
61
+
62
+ @router.get("/data/{data_id}")
63
+ async def get_explore_data(
64
+ data_id: str, state: tangram_core.InjectBackendState
65
+ ) -> Response:
66
+ explore_state = get_explore_state(state)
67
+ data = explore_state.data.get(data_id)
68
+ if data is None:
69
+ return Response(status_code=404) # shouldn't occur
70
+ return Response(content=data, media_type="application/vnd.apache.parquet")
71
+
72
+
73
+ @router.get("/layers")
74
+ async def get_layers(state: tangram_core.InjectBackendState) -> list[dict[str, Any]]:
75
+ explore_state = get_explore_state(state)
76
+ return [explore_state.layers[uid] for uid in explore_state.layer_order]
77
+
78
+
79
+ @runtime_checkable
80
+ class ArrowStreamExportable(Protocol):
81
+ def __arrow_c_stream__(self, requested_schema: Any = None) -> Any: ...
82
+
83
+
84
+ @runtime_checkable
85
+ class ExploreLayer(Protocol):
86
+ data: ArrowStreamExportable
87
+ """Any data structure that implements the
88
+ [Arrow C data interface](https://arrow.apache.org/docs/format/CDataInterface.html),
89
+ such as polars DataFrames or pyarrow Tables."""
90
+ label: str | None
91
+ """Unique name for the layer (optional).
92
+ If not provided, a random 8-character ID will be used."""
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class ScatterLayer(ExploreLayer):
97
+ data: ArrowStreamExportable
98
+ _: KW_ONLY
99
+ radius_scale: float = 50.0
100
+ radius_min_pixels: float = 2.0
101
+ radius_max_pixels: float = 5.0
102
+ line_width_min_pixels: float = 1.0
103
+ fill_color: str | list[int] = "#027ec7"
104
+ line_color: str | list[int] = "#000000"
105
+ opacity: float = 0.8
106
+ stroked: bool = False
107
+ filled: bool = True
108
+ pickable: bool = True
109
+ label: str | None = None
110
+ kind: Literal["scatter"] = field(default="scatter", init=False)
111
+
112
+
113
+ def _to_parquet_bytes(df: Any) -> bytes:
114
+ if not isinstance(df, ArrowStreamExportable):
115
+ raise TypeError(
116
+ f"cannot convert {type(df).__name__} to arrow. "
117
+ "object must implement '__arrow_c_stream__' protocol. "
118
+ )
119
+
120
+ reader = ac.RecordBatchReader.from_arrow(df)
121
+ sink = io.BytesIO()
122
+ arro3.io.write_parquet(reader, sink)
123
+ return sink.getvalue()
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class Layer:
128
+ id: str
129
+ _session: Session
130
+
131
+ async def remove(self) -> None:
132
+ await self._session.remove(self.id)
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class Session:
137
+ state: BackendState
138
+
139
+ def __post_init__(self) -> None:
140
+ if not hasattr(self.state, "explore_state"):
141
+ raise RuntimeError(
142
+ "The 'tangram_explore' plugin is not active. "
143
+ "Ensure it is added to the [core.plugins] list in your config."
144
+ )
145
+
146
+ async def _broadcast(self, op: str, **kwargs: Any) -> None:
147
+ payload = {"op": op, **kwargs}
148
+ topic = f"to:{EXPLORE_CHANNEL}:{EXPLORE_EVENT}"
149
+ await self.state.redis_client.publish(topic, orjson.dumps(payload))
150
+
151
+ async def push(self, layer: ExploreLayer) -> Layer:
152
+ if not is_dataclass(layer): # required for fields()
153
+ raise TypeError("layer must be a dataclass")
154
+ if not isinstance(layer, ExploreLayer):
155
+ raise TypeError("layer must implement ExploreLayer protocol")
156
+
157
+ data_id = str(uuid.uuid4())
158
+ parquet_bytes = _to_parquet_bytes(layer.data)
159
+
160
+ explore_state = get_explore_state(self.state)
161
+ explore_state.data[data_id] = parquet_bytes
162
+
163
+ style = {
164
+ f.name: getattr(layer, f.name)
165
+ for f in fields(layer)
166
+ if f.name not in ("data", "label")
167
+ }
168
+ label = getattr(layer, "label", None)
169
+
170
+ layer_def = {
171
+ "id": data_id,
172
+ "label": label or data_id[:8],
173
+ "url": f"/explore/data/{data_id}",
174
+ "style": style,
175
+ }
176
+ explore_state.layers[data_id] = layer_def
177
+ explore_state.layer_order.append(data_id)
178
+
179
+ await self._broadcast("add", layer=layer_def)
180
+
181
+ return Layer(id=data_id, _session=self)
182
+
183
+ async def remove(self, data_id: str) -> None:
184
+ explore_state = get_explore_state(self.state)
185
+ if data_id in explore_state.data:
186
+ del explore_state.data[data_id]
187
+ if data_id in explore_state.layers:
188
+ del explore_state.layers[data_id]
189
+ if data_id in explore_state.layer_order:
190
+ explore_state.layer_order.remove(data_id)
191
+ await self._broadcast("remove", id=data_id)
192
+
193
+ async def clear(self) -> None:
194
+ explore_state = get_explore_state(self.state)
195
+ explore_state.data.clear()
196
+ explore_state.layers.clear()
197
+ explore_state.layer_order.clear()
198
+ await self._broadcast("clear")
199
+
200
+
201
+ @dataclass
202
+ class ExploreConfig:
203
+ enable_3d: Annotated[Literal["inherit"] | bool, ExposeField()] = "inherit"
204
+ """Whether to render scatter points in 3D"""
205
+
206
+
207
+ plugin = tangram_core.Plugin(
208
+ frontend_path="dist-frontend",
209
+ routers=[router],
210
+ config_class=ExploreConfig,
211
+ lifespan=lifespan,
212
+ )
@@ -0,0 +1,96 @@
1
+ import type { TangramApi } from "@open-aviation/tangram-core/api";
2
+ import initWasm, { readParquet, wasmMemory } from "parquet-wasm";
3
+ import { parseTable } from "arrow-js-ffi";
4
+ import ExploreLayers from "./ExploreLayers.vue";
5
+ import LayerList from "./LayerList.vue";
6
+ import {
7
+ addLayer,
8
+ clearLayers,
9
+ removeLayer,
10
+ type StyleOptions,
11
+ pluginConfig
12
+ } from "./store";
13
+
14
+ const EXPLORE_CHANNEL = "explore";
15
+ const EXPLORE_EVENT = "layers";
16
+
17
+ let wasmInitialized = false;
18
+
19
+ async function initParquetWasm() {
20
+ if (wasmInitialized) return;
21
+ await initWasm("/parquet_wasm_bg.wasm");
22
+ wasmInitialized = true;
23
+ }
24
+
25
+ interface LayerDefinition {
26
+ id: string;
27
+ label: string;
28
+ url: string;
29
+ style: StyleOptions;
30
+ }
31
+
32
+ interface StackMessage {
33
+ op: "add" | "remove" | "clear";
34
+ layer?: LayerDefinition;
35
+ id?: string;
36
+ }
37
+
38
+ async function loadAndAdd(def: LayerDefinition) {
39
+ const resp = await fetch(def.url);
40
+ const arrayBuffer = await resp.arrayBuffer();
41
+ const data = new Uint8Array(arrayBuffer);
42
+
43
+ const wasmTable = readParquet(data);
44
+ const ffiTable = wasmTable.intoFFI();
45
+ const memory = wasmMemory().buffer;
46
+
47
+ const arrowTable = parseTable(
48
+ memory,
49
+ ffiTable.arrayAddrs(),
50
+ ffiTable.schemaAddr(),
51
+ true // copying is required because adding new layers can grow wasm memory and detach
52
+ );
53
+ addLayer(def.id, def.label, arrowTable, wasmTable, def.style);
54
+ }
55
+
56
+ async function handleMessage(payload: StackMessage) {
57
+ if (payload.op === "add" && payload.layer) {
58
+ await loadAndAdd(payload.layer);
59
+ } else if (payload.op === "clear") {
60
+ clearLayers();
61
+ } else if (payload.op === "remove" && payload.id) {
62
+ removeLayer(payload.id);
63
+ }
64
+ }
65
+
66
+ async function syncLayers() {
67
+ const res = await fetch("/explore/layers");
68
+ const layerDefs: LayerDefinition[] = await res.json();
69
+ clearLayers();
70
+ for (const def of layerDefs) {
71
+ await loadAndAdd(def);
72
+ }
73
+ }
74
+
75
+ export async function install(
76
+ api: TangramApi,
77
+ config?: { enable_3d?: boolean | "inherit" }
78
+ ) {
79
+ if (config) {
80
+ if (config.enable_3d !== undefined) pluginConfig.enable_3d = config.enable_3d;
81
+ }
82
+
83
+ api.ui.registerWidget("explore-layers-map", "MapOverlay", ExploreLayers);
84
+ api.ui.registerWidget("explore-layers-list", "SideBar", LayerList, {
85
+ title: "Explore Layers"
86
+ });
87
+
88
+ await initParquetWasm();
89
+ await api.realtime.ensureConnected();
90
+
91
+ api.realtime.subscribe<StackMessage>(
92
+ `${EXPLORE_CHANNEL}:${EXPLORE_EVENT}`,
93
+ handleMessage
94
+ );
95
+ await syncLayers();
96
+ }
@@ -0,0 +1,96 @@
1
+ import { reactive, shallowRef, triggerRef } from "vue";
2
+ import type { Table } from "apache-arrow";
3
+ import type { Table as WasmTable } from "parquet-wasm";
4
+
5
+ export type ColorSpec =
6
+ | string
7
+ | [number, number, number]
8
+ | [number, number, number, number];
9
+
10
+ export interface ScatterOptions {
11
+ kind: "scatter";
12
+ radius_scale: number;
13
+ radius_min_pixels: number;
14
+ radius_max_pixels: number;
15
+ line_width_min_pixels: number;
16
+ fill_color: ColorSpec;
17
+ line_color: ColorSpec;
18
+ opacity: number;
19
+ stroked: boolean;
20
+ filled: boolean;
21
+ pickable: boolean;
22
+ }
23
+
24
+ export type StyleOptions = ScatterOptions;
25
+
26
+ export interface LayerEntry {
27
+ id: string;
28
+ label: string;
29
+ table: Table;
30
+ wasmRef: WasmTable;
31
+ style: StyleOptions;
32
+ visible: boolean;
33
+ }
34
+
35
+ export const layers = shallowRef<LayerEntry[]>([]);
36
+
37
+ export const pluginConfig = reactive({
38
+ enable_3d: "inherit" as boolean | "inherit"
39
+ });
40
+
41
+ export function addLayer(
42
+ id: string,
43
+ label: string,
44
+ table: Table,
45
+ wasmRef: WasmTable,
46
+ style: StyleOptions
47
+ ) {
48
+ if (layers.value.some(d => d.id === id)) return;
49
+ layers.value.push({ id, label, table, wasmRef, style, visible: true });
50
+ triggerRef(layers);
51
+ }
52
+
53
+ export function removeLayer(id: string) {
54
+ const idx = layers.value.findIndex(d => d.id === id);
55
+ if (idx !== -1) {
56
+ const entry = layers.value[idx];
57
+ entry.wasmRef.free();
58
+ layers.value.splice(idx, 1);
59
+ triggerRef(layers);
60
+ }
61
+ }
62
+
63
+ export function clearLayers() {
64
+ layers.value.forEach(e => e.wasmRef.free());
65
+ layers.value = [];
66
+ triggerRef(layers);
67
+ }
68
+
69
+ export function toggleLayerVisibility(id: string) {
70
+ const layer = layers.value.find(d => d.id === id);
71
+ if (layer) {
72
+ layer.visible = !layer.visible;
73
+ triggerRef(layers);
74
+ }
75
+ }
76
+
77
+ export function parseColor(
78
+ c: ColorSpec,
79
+ defaultColor: [number, number, number, number]
80
+ ): [number, number, number, number] {
81
+ if (Array.isArray(c)) {
82
+ return c.length === 3
83
+ ? ([...c, 255] as [number, number, number, number])
84
+ : (c as [number, number, number, number]);
85
+ }
86
+ if (typeof c === "string" && c.startsWith("#")) {
87
+ const hex = c.slice(1);
88
+ if (hex.length === 6) {
89
+ const r = parseInt(hex.slice(0, 2), 16);
90
+ const g = parseInt(hex.slice(2, 4), 16);
91
+ const b = parseInt(hex.slice(4, 6), 16);
92
+ return [r, g, b, 255];
93
+ }
94
+ }
95
+ return defaultColor;
96
+ }
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from "vite";
2
+ import { tangramPlugin } from "@open-aviation/tangram-core/vite-plugin";
3
+
4
+ export default defineConfig({
5
+ plugins: [tangramPlugin()]
6
+ });