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.
- tangram_explore-0.3.0/.gitignore +173 -0
- tangram_explore-0.3.0/PKG-INFO +21 -0
- tangram_explore-0.3.0/examples/basic.py +35 -0
- tangram_explore-0.3.0/examples/basic_interactive.py +36 -0
- tangram_explore-0.3.0/package.json +24 -0
- tangram_explore-0.3.0/pyproject.toml +22 -0
- tangram_explore-0.3.0/readme.md +9 -0
- tangram_explore-0.3.0/src/tangram_explore/ExploreLayers.vue +172 -0
- tangram_explore-0.3.0/src/tangram_explore/LayerList.vue +170 -0
- tangram_explore-0.3.0/src/tangram_explore/__init__.py +212 -0
- tangram_explore-0.3.0/src/tangram_explore/index.ts +96 -0
- tangram_explore-0.3.0/src/tangram_explore/store.ts +96 -0
- tangram_explore-0.3.0/vite.config.ts +6 -0
|
@@ -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
|
+
}
|