Vue visualizations (#7773)
- Closes #7733 - Add infrastructure for defining custom visualizations - Add all visualizations # Important Notes ⚠️ Changes made: - "Fit all" has been changed to always animate - this is because behavior was previously inconsistent: - the scatterplot would always animate on "Fit all", but - the histogram would never animate on "Fit all"
1
app/gui2/.gitignore
vendored
@ -9,6 +9,7 @@ dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
6
app/gui2/env.d.ts
vendored
@ -1,3 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const PROJECT_MANAGER_URL: string
|
||||
|
||||
// This is an augmentation to the built-in `ImportMeta` interface.
|
||||
// This file MUST NOT contain any top-level imports.
|
||||
interface ImportMeta {
|
||||
vitest: typeof import('vitest') | undefined
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
<title>Enso GUI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
6
app/gui2/node.env.d.ts
vendored
@ -3,3 +3,9 @@ module 'tailwindcss/nesting' {
|
||||
declare const plugin: PluginCreator<unknown>
|
||||
export default plugin
|
||||
}
|
||||
|
||||
// This is an augmentation to the built-in `ImportMeta` interface.
|
||||
// This file MUST NOT contain any top-level imports.
|
||||
interface ImportMeta {
|
||||
vitest: typeof import('vitest') | undefined
|
||||
}
|
||||
|
@ -22,14 +22,19 @@
|
||||
"preinstall": "npm run build-rust-ffi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.22.16",
|
||||
"@open-rpc/client-js": "^1.8.1",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"enso-authentication": "^1.0.0",
|
||||
"hash-sum": "^2.0.0",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lib0": "^0.2.83",
|
||||
"magic-string": "^0.30.3",
|
||||
"pinia": "^2.1.6",
|
||||
"postcss-inline-svg": "^6.0.0",
|
||||
"postcss-nesting": "^12.0.1",
|
||||
"sha3": "^2.1.4",
|
||||
"sucrase": "^3.34.0",
|
||||
"vue": "^3.3.4",
|
||||
"ws": "^8.13.0",
|
||||
"y-protocols": "^1.0.5",
|
||||
@ -38,12 +43,16 @@
|
||||
"yjs": "^13.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@danmarshall/deckgl-typings": "^4.9.28",
|
||||
"@eslint/eslintrc": "^2.1.2",
|
||||
"@eslint/js": "^8.49.0",
|
||||
"@playwright/test": "^1.37.0",
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
"@tsconfig/node18": "^18.2.0",
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/hash-sum": "^1.0.0",
|
||||
"@types/jsdom": "^21.1.1",
|
||||
"@types/mapbox-gl": "^2.7.13",
|
||||
"@types/node": "^18.17.5",
|
||||
"@types/shuffle-seed": "^1.1.0",
|
||||
"@types/ws": "^8.5.5",
|
||||
@ -54,6 +63,8 @@
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"ag-grid-community": "^30.1.0",
|
||||
"ag-grid-enterprise": "^30.1.0",
|
||||
"esbuild": "^0.19.3",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-plugin-vue": "^9.16.1",
|
||||
@ -62,6 +73,7 @@
|
||||
"prettier": "^3.0.0",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"shuffle-seed": "^1.1.6",
|
||||
"sql-formatter": "^13.0.0",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^4.4.9",
|
||||
|
429
app/gui2/public/visualizations/GeoMapVisualization.vue
Normal file
@ -0,0 +1,429 @@
|
||||
<script lang="ts">
|
||||
export const name = 'Geo Map'
|
||||
export const inputType = 'Standard.Table.Data.Table.Table'
|
||||
export const scripts = [
|
||||
// mapbox-gl does not have an ESM release.
|
||||
'https://api.tiles.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js',
|
||||
// The deck.gl scripting API is not available in the ESM module.
|
||||
'https://cdn.jsdelivr.net/npm/deck.gl@8.9.27/dist.min.js',
|
||||
]
|
||||
|
||||
/**
|
||||
* Provides a mapbox & deck.gl-based map visualization.
|
||||
*
|
||||
* > Example creates a map with described properties with a scatter plot overlay:
|
||||
* {
|
||||
* "latitude": 37.8,
|
||||
* "longitude": -122.45,
|
||||
* "zoom": 15,
|
||||
* "controller": true,
|
||||
* "showingLabels": true, // Enables presenting labels when hovering over a point.
|
||||
* "layers": [{
|
||||
* "type": "Scatterplot_Layer",
|
||||
* "data": [{
|
||||
* "latitude": 37.8,
|
||||
* "longitude": -122.45,
|
||||
* "color": [255, 0, 0],
|
||||
* "radius": 100,
|
||||
* "label": "an example label"
|
||||
* }]
|
||||
* }]
|
||||
* }
|
||||
*
|
||||
* Can also consume a dataframe that has the columns `latitude`, `longitude` and optionally `label`.
|
||||
*
|
||||
* TODO: Make 2-finger panning behave like in IDE, and RMB zooming. [#1368]
|
||||
*/
|
||||
|
||||
type Data = RegularData | Layer | DataFrame
|
||||
|
||||
interface RegularData {
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
zoom?: number
|
||||
mapStyle?: string
|
||||
pitch?: number
|
||||
controller?: boolean
|
||||
showingLabels?: boolean
|
||||
layers: Layer[]
|
||||
}
|
||||
|
||||
interface Layer {
|
||||
type: string
|
||||
data: Location[]
|
||||
}
|
||||
|
||||
type Color = [red: number, green: number, blue: number]
|
||||
|
||||
interface Location {
|
||||
latitude: number
|
||||
longitude: number
|
||||
color?: Color | undefined
|
||||
radius?: number | undefined
|
||||
label?: string | undefined
|
||||
}
|
||||
|
||||
interface LocationWithPosition {
|
||||
position: [longitude: number, latitude: number]
|
||||
color?: Color
|
||||
radius?: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface DataFrame {
|
||||
df_latitude: number[]
|
||||
df_longitude: number[]
|
||||
df_color?: Color[]
|
||||
df_radius?: number[]
|
||||
df_label?: string[]
|
||||
}
|
||||
declare var deck: typeof import('deck.gl')
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
/// <reference types="@danmarshall/deckgl-typings" />
|
||||
import { computed, onMounted, onUnmounted, ref, watchPostEffect } from 'vue'
|
||||
|
||||
import type { Deck } from 'deck.gl'
|
||||
|
||||
import FindIcon from './icons/find.svg'
|
||||
import GeoMapDistanceIcon from './icons/geo_map_distance.svg'
|
||||
import GeoMapPinIcon from './icons/geo_map_pin.svg'
|
||||
import Path2Icon from './icons/path2.svg'
|
||||
|
||||
import VisualizationContainer from 'builtins/VisualizationContainer.vue'
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:processor': [module: string, method: string]
|
||||
}>()
|
||||
|
||||
/** GeoMap Visualization. */
|
||||
|
||||
/**
|
||||
* Mapbox API access token.
|
||||
* All the limits of API are listed here: https://docs.mapbox.com/api/#rate-limits
|
||||
*/
|
||||
const TOKEN =
|
||||
'pk.eyJ1IjoiZW5zby1vcmciLCJhIjoiY2tmNnh5MXh2MGlyOTJ5cWdubnFxbXo4ZSJ9.3KdAcCiiXJcSM18nwk09-Q'
|
||||
const SCATTERPLOT_LAYER = 'Scatterplot_Layer'
|
||||
const DEFAULT_POINT_RADIUS = 150
|
||||
|
||||
const LABEL_FONT = 'DejaVuSansMonoBook, sans-serif'
|
||||
const LABEL_FONT_SIZE = '12px'
|
||||
const LABEL_BORDER_RADIUS = '14px'
|
||||
const LABEL_BORDER_TOP_LEFT_RADIUS = '2px'
|
||||
const LABEL_MARGIN = '4px'
|
||||
const LABEL_BACKGROUND_COLOR = `rgb(252, 250, 245)`
|
||||
const LABEL_OUTLINE = `rgb(200, 210, 210)`
|
||||
const LABEL_COLOR = `rgba(0, 0, 0, 0.8)`
|
||||
const DEFAULT_MAP_STYLE = 'mapbox://styles/mapbox/light-v9'
|
||||
|
||||
const DEFAULT_MAP_ZOOM = 11
|
||||
const ACCENT_COLOR: Color = [78, 165, 253]
|
||||
|
||||
const dataPoints = ref<LocationWithPosition[]>([])
|
||||
const mapNode = ref<HTMLElement>()
|
||||
const latitude = ref(0)
|
||||
const longitude = ref(0)
|
||||
const zoom = ref(0)
|
||||
const mapStyle = ref(DEFAULT_MAP_STYLE)
|
||||
const pitch = ref(0)
|
||||
const controller = ref(true)
|
||||
const showingLabels = ref(true)
|
||||
const deckgl = ref<Deck>()
|
||||
|
||||
const viewState = computed(() => ({
|
||||
longitude: longitude.value,
|
||||
latitude: latitude.value,
|
||||
zoom: zoom.value,
|
||||
pitch: pitch.value,
|
||||
}))
|
||||
|
||||
watchPostEffect(() => {
|
||||
if (updateState(props.data)) {
|
||||
updateMap()
|
||||
updateLayers()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
dataPoints.value = []
|
||||
emit('update:processor', 'Standard.Visualization.Geo_Map', 'process_to_json_text')
|
||||
})
|
||||
|
||||
onUnmounted(() => deckgl.value?.finalize())
|
||||
|
||||
/**
|
||||
* Update the internal data with the new incoming data. Does not affect anything rendered.
|
||||
* Returns true on a successful update and false if no valid data was provided.
|
||||
*/
|
||||
function updateState(data: Data) {
|
||||
// For now we assume every update has all data. If we move to incremental updates we need
|
||||
// to keep the old state and do a proper update.
|
||||
resetState()
|
||||
|
||||
extractDataPoints(data)
|
||||
// eslint-disable-next-line no-self-assign
|
||||
dataPoints.value = dataPoints.value
|
||||
if (dataPoints.value.length === 0) {
|
||||
// We have no valid data and should skip initialization.
|
||||
return false
|
||||
}
|
||||
const center = centerPoint()
|
||||
|
||||
latitude.value = center.latitude
|
||||
longitude.value = center.longitude
|
||||
zoom.value = DEFAULT_MAP_ZOOM
|
||||
mapStyle.value = DEFAULT_MAP_STYLE
|
||||
pitch.value = 0
|
||||
controller.value = true
|
||||
showingLabels.value = true
|
||||
if (!('df_latitude' in data) && !('data' in data)) {
|
||||
latitude.value = data.latitude ?? center.latitude
|
||||
longitude.value = data.longitude ?? center.longitude
|
||||
// TODO: Compute zoom somehow from span of latitudes and longitudes.
|
||||
zoom.value = data.zoom ?? DEFAULT_MAP_ZOOM
|
||||
mapStyle.value = data.mapStyle ?? DEFAULT_MAP_STYLE
|
||||
pitch.value = data.pitch ?? 0
|
||||
controller.value = data.controller ?? true
|
||||
showingLabels.value = data.showingLabels ?? false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function updateMap() {
|
||||
if (deckgl.value == null) {
|
||||
initDeckGl()
|
||||
} else {
|
||||
updateDeckGl()
|
||||
}
|
||||
}
|
||||
|
||||
function initDeckGl() {
|
||||
if (mapNode.value == null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
deckgl.value = new deck.DeckGL({
|
||||
// The `...{}` spread operator suppresses TypeScript's excess property errors.
|
||||
// These are valid properties, but they do not exist in the typings.
|
||||
...{
|
||||
container: mapNode.value,
|
||||
mapboxApiAccessToken: TOKEN,
|
||||
mapStyle: mapStyle.value,
|
||||
},
|
||||
initialViewState: viewState.value,
|
||||
controller: controller.value,
|
||||
}) as any
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
resetState()
|
||||
resetDeckGl()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the internal state of the visualization, discarding all previous data updates.
|
||||
*/
|
||||
function resetState() {
|
||||
// We only need to reset the data points as everything else will be overwritten when new data
|
||||
// arrives.
|
||||
dataPoints.value = []
|
||||
}
|
||||
|
||||
function resetDeckGl() {
|
||||
deckgl.value = undefined
|
||||
resetMapElement()
|
||||
}
|
||||
|
||||
function resetMapElement() {
|
||||
const map = mapNode.value
|
||||
if (map == null) {
|
||||
console.warn('Geo Map visualization is missing its map container.')
|
||||
return
|
||||
}
|
||||
while (map.lastChild != null) {
|
||||
map.removeChild(map.lastChild)
|
||||
}
|
||||
}
|
||||
|
||||
function updateDeckGl() {
|
||||
const deckgl_ = deckgl.value
|
||||
if (deckgl_ == null) {
|
||||
console.warn('Geo Map could not update its deck.gl instance.')
|
||||
return
|
||||
}
|
||||
deckgl_.viewState = viewState.value
|
||||
// @ts-expect-error
|
||||
deckgl_.mapStyle = mapStyle.value
|
||||
// @ts-expect-error
|
||||
deckgl_.controller = controller.value
|
||||
}
|
||||
|
||||
function updateLayers() {
|
||||
if (deckgl.value == null) {
|
||||
console.warn(
|
||||
'Geo Map visualization could not update its layers ' +
|
||||
'due to its deck.gl instance being missing.',
|
||||
)
|
||||
return
|
||||
}
|
||||
;(deckgl.value as any).setProps({
|
||||
layers: [
|
||||
new deck.ScatterplotLayer<LocationWithPosition>({
|
||||
data: dataPoints.value,
|
||||
getFillColor: (d) => d.color!,
|
||||
getRadius: (d) => d.radius!,
|
||||
pickable: showingLabels.value,
|
||||
}),
|
||||
],
|
||||
getTooltip: ({ object }: { object: { label: string } }) =>
|
||||
object && {
|
||||
html: `<div>${object.label}</div>`,
|
||||
style: {
|
||||
backgroundColor: LABEL_BACKGROUND_COLOR,
|
||||
fontSize: LABEL_FONT_SIZE,
|
||||
borderRadius: LABEL_BORDER_RADIUS,
|
||||
borderTopLeftRadius: LABEL_BORDER_TOP_LEFT_RADIUS,
|
||||
fontFamily: LABEL_FONT,
|
||||
margin: LABEL_MARGIN,
|
||||
color: LABEL_COLOR,
|
||||
border: '1px solid ' + LABEL_OUTLINE,
|
||||
// This is required for it to show above Mapbox's information button.
|
||||
zIndex: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the center of the bounding box of the given list of objects. The objects need to have
|
||||
* a `position` attribute with two coordinates.
|
||||
*/
|
||||
function centerPoint() {
|
||||
let minX = 0
|
||||
let maxX = 0
|
||||
let minY = 0
|
||||
let maxY = 0
|
||||
{
|
||||
const xs = dataPoints.value.map((p) => p.position[0])
|
||||
minX = Math.min(...xs)
|
||||
maxX = Math.min(...xs)
|
||||
}
|
||||
{
|
||||
const ys = dataPoints.value.map((p) => p.position[1])
|
||||
minY = Math.min(...ys)
|
||||
maxY = Math.min(...ys)
|
||||
}
|
||||
let longitude = (minX + maxX) / 2
|
||||
let latitude = (minY + maxY) / 2
|
||||
return { latitude, longitude }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the visualization data from a full configuration object.
|
||||
*/
|
||||
function extractVisualizationDataFromFullConfig(parsedData: RegularData | Layer) {
|
||||
if ('type' in parsedData && parsedData.type === SCATTERPLOT_LAYER && parsedData.data.length) {
|
||||
pushPoints(parsedData.data)
|
||||
} else if ('layers' in parsedData) {
|
||||
parsedData.layers.forEach((layer) => {
|
||||
if (layer.type === SCATTERPLOT_LAYER) {
|
||||
let dataPoints = layer.data ?? []
|
||||
pushPoints(dataPoints)
|
||||
} else {
|
||||
console.warn('Geo_Map: Currently unsupported deck.gl layer.')
|
||||
}
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line no-self-assign
|
||||
dataPoints.value = dataPoints.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the visualization data from a dataframe.
|
||||
*/
|
||||
function extractVisualizationDataFromDataFrame(parsedData: DataFrame) {
|
||||
const newPoints: Location[] = []
|
||||
for (let i = 0; i < parsedData.df_latitude.length; i += 1) {
|
||||
const latitude = parsedData.df_longitude[i]!
|
||||
const longitude = parsedData.df_longitude[i]!
|
||||
const label = parsedData.df_label?.[i]
|
||||
const color = parsedData.df_color?.[i]
|
||||
const radius = parsedData.df_radius?.[i]
|
||||
newPoints.push({ latitude, longitude, label, color, radius })
|
||||
}
|
||||
pushPoints(newPoints)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the data form the given `parsedData`. Checks the type of input data and prepares our
|
||||
* internal data (`GeoPoints') for consumption in deck.gl.
|
||||
*
|
||||
* @param parsedData - All the parsed data to create points from.
|
||||
* @param preparedDataPoints - List holding data points to push the GeoPoints into.
|
||||
* @param ACCENT_COLOR - accent color of IDE if element doesn't specify one.
|
||||
*/
|
||||
function extractDataPoints(parsedData: Data) {
|
||||
if ('df_latitude' in parsedData && 'df_longitude' in parsedData) {
|
||||
extractVisualizationDataFromDataFrame(parsedData)
|
||||
} else {
|
||||
extractVisualizationDataFromFullConfig(parsedData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the `dataPoints` to the internal data format and appends them to the `targetList`.
|
||||
* Also adds the `ACCENT_COLOR` for each point.
|
||||
*
|
||||
* Expects the `dataPoints` to be a list of objects that have a `longitude` and `latitude` and
|
||||
* optionally `radius`, `color` and `label`.
|
||||
*/
|
||||
function pushPoints(newPoints: Location[]) {
|
||||
const points = dataPoints.value
|
||||
for (const point of newPoints) {
|
||||
if (
|
||||
typeof point.longitude === 'number' &&
|
||||
!Number.isNaN(point.longitude) &&
|
||||
typeof point.latitude === 'number' &&
|
||||
!Number.isNaN(point.latitude)
|
||||
) {
|
||||
let position: [number, number] = [point.longitude, point.latitude]
|
||||
let radius =
|
||||
typeof point.radius === 'number' && !Number.isNaN(point.radius)
|
||||
? point.radius
|
||||
: DEFAULT_POINT_RADIUS
|
||||
let color = point.color ?? ACCENT_COLOR
|
||||
let label = point.label ?? ''
|
||||
points.push({ position, color, radius, label })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :overflow="true">
|
||||
<template #toolbar>
|
||||
<button class="image-button"><img :src="FindIcon" /></button>
|
||||
<button class="image-button"><img :src="Path2Icon" /></button>
|
||||
<button class="image-button"><img :src="GeoMapDistanceIcon" /></button>
|
||||
<button class="image-button"><img :src="GeoMapPinIcon" /></button>
|
||||
</template>
|
||||
<div ref="mapNode" class="GeoMapVisualization" @wheel.stop></div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://api.tiles.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css');
|
||||
|
||||
.GeoMapVisualization {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.GeoMapVisualization > .mapboxgl-map {
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
627
app/gui2/public/visualizations/ScatterplotVisualization.vue
Normal file
@ -0,0 +1,627 @@
|
||||
<script lang="ts">
|
||||
export const name = 'Scatterplot'
|
||||
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
|
||||
|
||||
// eslint-disable-next-line no-redeclare
|
||||
declare const d3: typeof import('d3')
|
||||
|
||||
/**
|
||||
* A d3.js Scatterplot visualization.
|
||||
*
|
||||
* To zoom use scroll wheel.
|
||||
* To select click and swipe with LMB.
|
||||
* To deselect click outside of selection with LMB.
|
||||
* To pan click and swipe with RMB.
|
||||
* To zoom out click "Fit all" or use key combination "ctrl/cmd+a".
|
||||
* To zoom into selection click appropriate button or use key combination "ctrl/cmd+z".
|
||||
*
|
||||
* Data format (JSON):
|
||||
* {
|
||||
* "axis":{
|
||||
* "x":{"label":"x-axis label","scale":"linear"},
|
||||
* "y":{"label":"y-axis label","scale":"logarithmic"},
|
||||
* },
|
||||
* "focus":{"x":1.7,"y":2.1,"zoom":3.0},
|
||||
* "points":{"labels":"visible" | "invisible"},
|
||||
* "data":[
|
||||
* {"x":0.1,"y":0.7,"label":"foo","color":"FF0000","shape":"circle","size":0.2},
|
||||
* ...
|
||||
* {"x":0.4,"y":0.2,"label":"baz","color":"0000FF","shape":"square","size":0.3}
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
|
||||
interface Data {
|
||||
axis: AxesConfiguration
|
||||
focus: Focus | undefined
|
||||
points: PointsConfiguration
|
||||
data: Point[]
|
||||
}
|
||||
|
||||
interface Focus {
|
||||
x: number
|
||||
y: number
|
||||
zoom: number
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
label?: string
|
||||
color?: string
|
||||
shape?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface PointsConfiguration {
|
||||
labels: string
|
||||
}
|
||||
|
||||
enum ScaleType {
|
||||
Linear = 'linear',
|
||||
Logarithmic = 'logarithmic',
|
||||
}
|
||||
|
||||
interface AxisConfiguration {
|
||||
label: string
|
||||
scale: ScaleType
|
||||
}
|
||||
|
||||
interface AxesConfiguration {
|
||||
x: AxisConfiguration
|
||||
y: AxisConfiguration
|
||||
}
|
||||
|
||||
interface Color {
|
||||
red: number
|
||||
green: number
|
||||
blue: number
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
|
||||
import FindIcon from './icons/find.svg'
|
||||
import ShowAllIcon from './icons/show_all.svg'
|
||||
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line no-redeclare
|
||||
import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7.8.5/+esm'
|
||||
|
||||
import type {
|
||||
BrushSelection,
|
||||
D3BrushEvent,
|
||||
D3ZoomEvent,
|
||||
ScaleContinuousNumeric,
|
||||
SymbolType,
|
||||
} from 'd3'
|
||||
|
||||
import { useEvent, useEventConditional } from './events.ts'
|
||||
import { getTextWidth } from './measurement.ts'
|
||||
|
||||
import VisualizationContainer from 'builtins/VisualizationContainer.vue'
|
||||
import { useVisualizationConfig } from 'builtins/useVisualizationConfig.ts'
|
||||
|
||||
import type { Symbol } from 'd3'
|
||||
|
||||
const props = defineProps<{ data: Partial<Data> | number[] }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
// TODO [sb]: Consider switching to a global keyboard shortcut handler.
|
||||
const shortcuts = {
|
||||
zoomIn: (e: KeyboardEvent) => (e.ctrlKey || e.metaKey) && e.key === 'z',
|
||||
showAll: (e: KeyboardEvent) => (e.ctrlKey || e.metaKey) && e.key === 'a',
|
||||
}
|
||||
|
||||
const LABEL_FONT_STYLE = '10px DejaVuSansMonoBook'
|
||||
const POINT_LABEL_PADDING_X_PX = 7
|
||||
const POINT_LABEL_PADDING_Y_PX = 2
|
||||
const ANIMATION_DURATION_MS = 400
|
||||
const VISIBLE_POINTS = 'visible'
|
||||
const DEFAULT_LIMIT = 1024
|
||||
const ACCENT_COLOR: Color = { red: 78, green: 165, blue: 253 }
|
||||
const SIZE_SCALE_MULTIPLER = 100
|
||||
const FILL_COLOR = `rgba(${ACCENT_COLOR.red * 255},${ACCENT_COLOR.green * 255},${
|
||||
ACCENT_COLOR.blue * 255
|
||||
},0.8)`
|
||||
|
||||
const ZOOM_EXTENT = [0.5, 20] satisfies BrushSelection
|
||||
const RIGHT_BUTTON = 2
|
||||
const MID_BUTTON = 1
|
||||
const MID_BUTTON_CLICKED = 4
|
||||
const SCROLL_WHEEL = 0
|
||||
|
||||
const SHAPE_TO_SYMBOL: Record<string, SymbolType> = {
|
||||
cross: d3.symbolCross,
|
||||
diamond: d3.symbolDiamond,
|
||||
square: d3.symbolSquare,
|
||||
star: d3.symbolStar,
|
||||
triangle: d3.symbolTriangle,
|
||||
}
|
||||
|
||||
const SCALE_TO_D3_SCALE: Record<ScaleType, () => ScaleContinuousNumeric<number, number>> = {
|
||||
[ScaleType.Linear]: () => d3.scaleLinear(),
|
||||
[ScaleType.Logarithmic]: () => d3.scaleLog(),
|
||||
}
|
||||
|
||||
const data = computed<Data>(() => {
|
||||
let rawData = props.data
|
||||
const unfilteredData = Array.isArray(rawData)
|
||||
? rawData.map((y, index) => ({ x: index, y }))
|
||||
: rawData.data ?? []
|
||||
const data: Point[] = unfilteredData.filter(
|
||||
(point) =>
|
||||
typeof point.x === 'number' &&
|
||||
!Number.isNaN(point.x) &&
|
||||
typeof point.y === 'number' &&
|
||||
!Number.isNaN(point.y),
|
||||
)
|
||||
if (Array.isArray(rawData)) {
|
||||
rawData = {}
|
||||
}
|
||||
const axis: AxesConfiguration = rawData.axis ?? {
|
||||
x: { label: '', scale: ScaleType.Linear },
|
||||
y: { label: '', scale: ScaleType.Linear },
|
||||
}
|
||||
const points = rawData.points ?? { labels: 'visible' }
|
||||
const focus: Focus | undefined = rawData.focus
|
||||
return { axis, points, data, focus }
|
||||
})
|
||||
|
||||
const containerNode = ref<HTMLElement>()
|
||||
const pointsNode = ref<SVGGElement>()
|
||||
const xAxisNode = ref<SVGGElement>()
|
||||
const yAxisNode = ref<SVGGElement>()
|
||||
const zoomNode = ref<SVGGElement>()
|
||||
const brushNode = ref<SVGGElement>()
|
||||
|
||||
const d3Points = computed(() => d3.select(pointsNode.value))
|
||||
const d3XAxis = computed(() => d3.select(xAxisNode.value))
|
||||
const d3YAxis = computed(() => d3.select(yAxisNode.value))
|
||||
const d3Zoom = computed(() => d3.select(zoomNode.value))
|
||||
const d3Brush = computed(() => d3.select(brushNode.value))
|
||||
|
||||
const bounds = ref<[number, number, number, number]>()
|
||||
const brushExtent = ref<BrushSelection>()
|
||||
const limit = ref(DEFAULT_LIMIT)
|
||||
const focus = ref<Focus>()
|
||||
const shouldAnimate = ref(false)
|
||||
const xDomain = ref<[min: number, max: number]>([0, 1])
|
||||
const yDomain = ref<[min: number, max: number]>([0, 1])
|
||||
|
||||
const xScale = computed(() =>
|
||||
axisD3Scale(data.value.axis.x).domain(xDomain.value).range([0, boxWidth.value]),
|
||||
)
|
||||
const yScale = computed(() =>
|
||||
axisD3Scale(data.value.axis.y).domain(yDomain.value).range([boxHeight.value, 0]),
|
||||
)
|
||||
|
||||
const symbol: Symbol<unknown, Point> = d3.symbol()
|
||||
|
||||
const animationDuration = computed(() => (shouldAnimate.value ? ANIMATION_DURATION_MS : 0))
|
||||
const margin = computed(() => {
|
||||
const xLabel = data.value.axis.x.label
|
||||
const yLabel = data.value.axis.y.label
|
||||
if (xLabel == null && yLabel === null) {
|
||||
return { top: 20, right: 20, bottom: 20, left: 45 }
|
||||
} else if (yLabel == null) {
|
||||
return { top: 10, right: 20, bottom: 35, left: 35 }
|
||||
} else if (xLabel == null) {
|
||||
return { top: 20, right: 10, bottom: 20, left: 55 }
|
||||
} else {
|
||||
return { top: 10, right: 10, bottom: 35, left: 55 }
|
||||
}
|
||||
})
|
||||
const width = ref(Math.max(config.value.width ?? 0, config.value.nodeSize.x))
|
||||
watchPostEffect(() => {
|
||||
width.value = config.value.fullscreen
|
||||
? containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
: Math.max(config.value.width ?? 0, config.value.nodeSize.x)
|
||||
})
|
||||
const height = ref(config.value.height ?? (config.value.nodeSize.x * 3) / 4)
|
||||
watchPostEffect(() => {
|
||||
height.value = config.value.fullscreen
|
||||
? containerNode.value?.parentElement?.clientHeight ?? 0
|
||||
: config.value.height ?? (config.value.nodeSize.x * 3) / 4
|
||||
})
|
||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
||||
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
||||
const xTicks = computed(() => boxWidth.value / 40)
|
||||
const yTicks = computed(() => boxHeight.value / 20)
|
||||
const xLabelLeft = computed(
|
||||
() =>
|
||||
margin.value.left +
|
||||
boxWidth.value / 2 -
|
||||
getTextWidth(data.value.axis.x.label, LABEL_FONT_STYLE) / 2,
|
||||
)
|
||||
const xLabelTop = computed(() => boxHeight.value + margin.value.top + 20)
|
||||
const yLabelLeft = computed(
|
||||
() => -boxHeight.value / 2 + getTextWidth(data.value.axis.y.label, LABEL_FONT_STYLE) / 2,
|
||||
)
|
||||
const yLabelTop = computed(() => -margin.value.left + 15)
|
||||
|
||||
function updatePreprocessor() {
|
||||
emit(
|
||||
'update:preprocessor',
|
||||
'Standard.Visualization.Scatter_Plot',
|
||||
'process_to_json_text',
|
||||
bounds.value == null ? 'Nothing' : '[' + bounds.value.join(',') + ']',
|
||||
limit.value.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(updatePreprocessor)
|
||||
|
||||
watchEffect(() => (focus.value = data.value.focus))
|
||||
|
||||
/**
|
||||
* Helper function calculating extreme values and paddings to make sure data will fit nicely.
|
||||
*
|
||||
* It traverses through data getting minimal and maximal values, and calculates padding based on
|
||||
* span calculated from above values, multiplied by 10% so that the plot is a little bit smaller
|
||||
* than the container.
|
||||
*/
|
||||
const extremesAndDeltas = computed(() => {
|
||||
const [xMin = 0, xMax = 0] = d3.extent(data.value.data, (point) => point.x)
|
||||
const [yMin = 0, yMax = 0] = d3.extent(data.value.data, (point) => point.y)
|
||||
const dx = xMax - xMin
|
||||
const dy = yMax - yMin
|
||||
const paddingX = 0.1 * dx
|
||||
const paddingY = 0.1 * dy
|
||||
return { xMin, xMax, yMin, yMax, paddingX, paddingY, dx, dy }
|
||||
})
|
||||
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let actionStartXScale = xScale.value.copy()
|
||||
let actionStartYScale = yScale.value.copy()
|
||||
|
||||
const zoom = computed(() =>
|
||||
d3
|
||||
.zoom<SVGGElement, unknown>()
|
||||
.filter((event: Event) => {
|
||||
if (
|
||||
event instanceof MouseEvent &&
|
||||
event.type === 'mousedown' &&
|
||||
(event.button === RIGHT_BUTTON || event.button === MID_BUTTON)
|
||||
) {
|
||||
return true
|
||||
} else if (
|
||||
event instanceof WheelEvent &&
|
||||
event.type === 'wheel' &&
|
||||
event.button === SCROLL_WHEEL
|
||||
) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.wheelDelta((event) => {
|
||||
const minDelta = 0.002
|
||||
const medDelta = 0.05
|
||||
const maxDelta = 1
|
||||
const wheelSpeedMultiplier =
|
||||
event.deltaMode === 1 ? medDelta : event.deltaMode ? maxDelta : minDelta
|
||||
return -event.deltaY * wheelSpeedMultiplier
|
||||
})
|
||||
.scaleExtent(ZOOM_EXTENT)
|
||||
.extent([
|
||||
[0, 0],
|
||||
[boxWidth.value, boxHeight.value],
|
||||
])
|
||||
.on('zoom', zoomed)
|
||||
.on('start', startZoom),
|
||||
)
|
||||
watchEffect(() => d3Zoom.value.call(zoom.value))
|
||||
|
||||
/** Helper function called on pan/scroll. */
|
||||
function zoomed(event: D3ZoomEvent<Element, unknown>) {
|
||||
shouldAnimate.value = false
|
||||
const xScale_ = xScale.value
|
||||
const yScale_ = yScale.value
|
||||
|
||||
function innerRescale(distanceScale: d3.ZoomTransform) {
|
||||
xDomain.value = distanceScale.rescaleX(xScale_).domain()
|
||||
yDomain.value = distanceScale.rescaleY(yScale_).domain()
|
||||
}
|
||||
|
||||
function getScaleForZoom(scale: number) {
|
||||
return d3.zoomIdentity
|
||||
.translate(startX - margin.value.left, startY - margin.value.top)
|
||||
.scale(scale)
|
||||
.translate(-startX + margin.value.left, -startY + margin.value.top)
|
||||
}
|
||||
|
||||
if (event.sourceEvent instanceof MouseEvent && event.sourceEvent.buttons === RIGHT_BUTTON) {
|
||||
xScale_.domain(actionStartXScale.domain())
|
||||
yScale_.domain(actionStartYScale.domain())
|
||||
const rmbDivider = 100
|
||||
const zoomAmount = rmbZoomValue(event.sourceEvent) / rmbDivider
|
||||
const distanceScale = getScaleForZoom(Math.exp(zoomAmount))
|
||||
innerRescale(distanceScale)
|
||||
} else if (event.sourceEvent instanceof WheelEvent) {
|
||||
if (event.sourceEvent.ctrlKey) {
|
||||
const pinchDivider = 100
|
||||
const zoomAmount = -event.sourceEvent.deltaY / pinchDivider
|
||||
const distanceScale = getScaleForZoom(Math.exp(zoomAmount))
|
||||
innerRescale(distanceScale)
|
||||
} else {
|
||||
const distanceScale = d3.zoomIdentity.translate(
|
||||
-event.sourceEvent.deltaX,
|
||||
-event.sourceEvent.deltaY,
|
||||
)
|
||||
innerRescale(distanceScale)
|
||||
}
|
||||
} else if (
|
||||
event.sourceEvent instanceof MouseEvent &&
|
||||
event.sourceEvent.buttons === MID_BUTTON_CLICKED
|
||||
) {
|
||||
const movementFactor = 2
|
||||
const distanceScale = d3.zoomIdentity.translate(
|
||||
event.sourceEvent.movementX / movementFactor,
|
||||
event.sourceEvent.movementY / movementFactor,
|
||||
)
|
||||
innerRescale(distanceScale)
|
||||
} else {
|
||||
innerRescale(event.transform)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the zoom value computed from the initial right-mouse-button event to the current
|
||||
* right-mouse event.
|
||||
*/
|
||||
function rmbZoomValue(event: MouseEvent | WheelEvent | undefined) {
|
||||
const dX = (event?.offsetX ?? 0) - startX
|
||||
const dY = (event?.offsetY ?? 0) - startY
|
||||
return dX - dY
|
||||
}
|
||||
|
||||
/** Helper function called when starting to pan/scroll. */
|
||||
function startZoom(event: D3ZoomEvent<Element, unknown>) {
|
||||
startX = event.sourceEvent?.offsetX ?? 0
|
||||
startY = event.sourceEvent?.offsetY ?? 0
|
||||
actionStartXScale = xScale.value.copy()
|
||||
actionStartYScale = yScale.value.copy()
|
||||
}
|
||||
|
||||
const brush = computed(() =>
|
||||
d3
|
||||
.brush()
|
||||
.extent([
|
||||
[0, 0],
|
||||
[boxWidth.value, boxHeight.value],
|
||||
])
|
||||
.on('start brush', (event: D3BrushEvent<unknown>) => {
|
||||
brushExtent.value = event.selection ?? undefined
|
||||
}),
|
||||
)
|
||||
watchEffect(() => d3Brush.value.call(brush.value))
|
||||
|
||||
/** Zoom into the selected area of the plot.
|
||||
*
|
||||
* Based on https://www.d3-graph-gallery.com/graph/interactivity_brush.html
|
||||
* Section "Brushing for zooming". */
|
||||
function zoomToSelected() {
|
||||
shouldAnimate.value = true
|
||||
focus.value = undefined
|
||||
if (
|
||||
brushExtent.value == null ||
|
||||
!Array.isArray(brushExtent.value[0]) ||
|
||||
!Array.isArray(brushExtent.value[1])
|
||||
) {
|
||||
return
|
||||
}
|
||||
const xScale_ = xScale.value
|
||||
const yScale_ = yScale.value
|
||||
const [[xMinRaw, yMaxRaw], [xMaxRaw, yMinRaw]] = brushExtent.value
|
||||
const xMin = xScale_.invert(xMinRaw)
|
||||
const xMax = xScale_.invert(xMaxRaw)
|
||||
const yMin = yScale_.invert(yMinRaw)
|
||||
const yMax = yScale_.invert(yMaxRaw)
|
||||
bounds.value = [xMin, yMin, xMax, yMax]
|
||||
updatePreprocessor()
|
||||
xDomain.value = [xMin, xMax]
|
||||
yDomain.value = [yMin, yMax]
|
||||
}
|
||||
|
||||
useEventConditional(
|
||||
document,
|
||||
'keydown',
|
||||
() => brushExtent.value != null,
|
||||
(event) => {
|
||||
if (shortcuts.zoomIn(event)) {
|
||||
zoomToSelected()
|
||||
endBrushing()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch([boxWidth, boxHeight], () => (shouldAnimate.value = false))
|
||||
|
||||
/** Helper function to match a d3 shape from its name. */
|
||||
function matchShape(d: Point) {
|
||||
return d.shape != null ? SHAPE_TO_SYMBOL[d.shape] ?? d3.symbolCircle : d3.symbolCircle
|
||||
}
|
||||
|
||||
/** Construct either a linear or a logarithmic D3 scale.
|
||||
*
|
||||
* The scale kind is selected depending on update contents.
|
||||
*
|
||||
* @param axis Axis information as received in the visualization update.
|
||||
* @returns D3 scale. */
|
||||
function axisD3Scale(axis: AxisConfiguration | undefined) {
|
||||
return axis != null ? SCALE_TO_D3_SCALE[axis.scale]() : d3.scaleLinear()
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
// Update the axes in d3.
|
||||
const { xMin, xMax, yMin, yMax, paddingX, paddingY, dx, dy } = extremesAndDeltas.value
|
||||
const focus_ = focus.value
|
||||
if (focus_?.x != null && focus_.y != null && focus_.zoom != null) {
|
||||
const newPaddingX = dx * (1 / (2 * focus_.zoom))
|
||||
const newPaddingY = dy * (1 / (2 * focus_.zoom))
|
||||
xDomain.value = [focus_.x - newPaddingX, focus_.x + newPaddingX]
|
||||
yDomain.value = [focus_.y - newPaddingY, focus_.y + newPaddingY]
|
||||
} else {
|
||||
xDomain.value = [xMin - paddingX, xMax + paddingX]
|
||||
yDomain.value = [yMin - paddingY, yMax + paddingY]
|
||||
}
|
||||
})
|
||||
|
||||
// ==============
|
||||
// === Update ===
|
||||
// ==============
|
||||
|
||||
// === Update x axis ===
|
||||
|
||||
watchPostEffect(() =>
|
||||
d3XAxis.value
|
||||
.transition()
|
||||
.duration(animationDuration.value)
|
||||
.call(d3.axisBottom(xScale.value).ticks(xTicks.value)),
|
||||
)
|
||||
|
||||
// === Update y axis ===
|
||||
|
||||
watchPostEffect(() =>
|
||||
d3YAxis.value
|
||||
.transition()
|
||||
.duration(animationDuration.value)
|
||||
.call(d3.axisLeft(yScale.value).ticks(yTicks.value)),
|
||||
)
|
||||
|
||||
// === Update contents ===
|
||||
|
||||
watchPostEffect(() => {
|
||||
const xScale_ = xScale.value
|
||||
const yScale_ = yScale.value
|
||||
d3Points.value
|
||||
.selectAll<SVGPathElement, unknown>('path')
|
||||
.data(data.value.data)
|
||||
.join((enter) => enter.append('path'))
|
||||
.transition()
|
||||
.duration(animationDuration.value)
|
||||
.attr(
|
||||
'd',
|
||||
symbol.type(matchShape).size((d) => (d.size ?? 1.0) * SIZE_SCALE_MULTIPLER),
|
||||
)
|
||||
.style('fill', (d) => d.color ?? FILL_COLOR)
|
||||
.attr('transform', (d) => `translate(${xScale_(d.x)}, ${yScale_(d.y)})`)
|
||||
if (data.value.points.labels === VISIBLE_POINTS) {
|
||||
d3Points.value
|
||||
.selectAll<SVGPathElement, unknown>('text')
|
||||
.data(data.value.data)
|
||||
.join((enter) => enter.append('text').attr('class', 'label'))
|
||||
.transition()
|
||||
.duration(animationDuration.value)
|
||||
.text((d) => d.label ?? '')
|
||||
.attr('x', (d) => xScale_(d.x) + POINT_LABEL_PADDING_X_PX)
|
||||
.attr('y', (d) => yScale_(d.y) + POINT_LABEL_PADDING_Y_PX)
|
||||
}
|
||||
})
|
||||
|
||||
// ======================
|
||||
// === Event handlers ===
|
||||
// ======================
|
||||
|
||||
function fitAll() {
|
||||
shouldAnimate.value = true
|
||||
focus.value = undefined
|
||||
bounds.value = undefined
|
||||
limit.value = DEFAULT_LIMIT
|
||||
xDomain.value = [
|
||||
extremesAndDeltas.value.xMin - extremesAndDeltas.value.paddingX,
|
||||
extremesAndDeltas.value.xMax + extremesAndDeltas.value.paddingX,
|
||||
]
|
||||
yDomain.value = [
|
||||
extremesAndDeltas.value.yMin - extremesAndDeltas.value.paddingY,
|
||||
extremesAndDeltas.value.yMax + extremesAndDeltas.value.paddingY,
|
||||
]
|
||||
updatePreprocessor()
|
||||
}
|
||||
|
||||
function endBrushing() {
|
||||
brushExtent.value = undefined
|
||||
d3Brush.value.call(brush.value.move, null)
|
||||
}
|
||||
|
||||
useEvent(document, 'keydown', (event) => {
|
||||
if (shortcuts.showAll(event)) {
|
||||
fitAll()
|
||||
}
|
||||
})
|
||||
useEvent(document, 'click', endBrushing)
|
||||
useEvent(document, 'auxclick', endBrushing)
|
||||
useEvent(document, 'contextmenu', endBrushing)
|
||||
useEvent(document, 'scroll', endBrushing)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<template #toolbar>
|
||||
<button class="image-button active">
|
||||
<img :src="ShowAllIcon" alt="Fit all" @click="fitAll" />
|
||||
</button>
|
||||
<button class="image-button" :class="{ active: brushExtent != null }">
|
||||
<img :src="FindIcon" alt="Zoom to selected" @click="zoomToSelected" />
|
||||
</button>
|
||||
</template>
|
||||
<div ref="containerNode" class="ScatterplotVisualization" @pointerdown.stop>
|
||||
<svg :width="width" :height="height">
|
||||
<g :transform="`translate(${margin.left}, ${margin.top})`">
|
||||
<defs>
|
||||
<clipPath id="clip">
|
||||
<rect :width="boxWidth" :height="boxHeight"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
|
||||
<g ref="yAxisNode" class="axis-y"></g>
|
||||
<text
|
||||
v-if="data.axis.x.label"
|
||||
class="label label-x"
|
||||
text-anchor="end"
|
||||
:x="xLabelLeft"
|
||||
:y="xLabelTop"
|
||||
v-text="data.axis.x.label"
|
||||
></text>
|
||||
<text
|
||||
v-if="data.axis.y.label"
|
||||
class="label label-y"
|
||||
text-anchor="end"
|
||||
:x="yLabelLeft"
|
||||
:y="yLabelTop"
|
||||
v-text="data.axis.y.label"
|
||||
></text>
|
||||
<g ref="pointsNode" clip-path="url(#clip)"></g>
|
||||
<g ref="zoomNode" class="zoom" :width="boxWidth" :height="boxHeight" fill="none">
|
||||
<g ref="brushNode" class="brush"></g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://fonts.cdnfonts.com/css/dejavu-sans-mono');
|
||||
|
||||
.ScatterplotVisualization {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ScatterplotVisualization .selection {
|
||||
rx: 4px;
|
||||
stroke: transparent;
|
||||
}
|
||||
|
||||
.label-y {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
</style>
|
31
app/gui2/public/visualizations/builtins.ts
Normal file
@ -0,0 +1,31 @@
|
||||
export interface Vec2 {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
export interface RGBA {
|
||||
red: number
|
||||
green: number
|
||||
blue: number
|
||||
alpha: number
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
getColorForType(type: string): RGBA
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME: Theme = {
|
||||
getColorForType(type) {
|
||||
let hash = 0
|
||||
for (const c of type) {
|
||||
hash = 0 | (hash * 31 + c.charCodeAt(0))
|
||||
}
|
||||
if (hash < 0) {
|
||||
hash += 0x80000000
|
||||
}
|
||||
const red = (hash >> 24) / 0x180
|
||||
const green = ((hash >> 16) & 0xff) / 0x180
|
||||
const blue = ((hash >> 8) & 0xff) / 0x180
|
||||
return { red, green, blue, alpha: 1 }
|
||||
},
|
||||
}
|
36
app/gui2/public/visualizations/dependencyTypesPatches.ts
Normal file
@ -0,0 +1,36 @@
|
||||
// Fixes and extensions for dependencies' type definitions.
|
||||
|
||||
import type * as d3Types from 'd3'
|
||||
|
||||
declare module 'd3' {
|
||||
// d3 treats `null` and `undefined` as a selection of 0 elements, so they are a valid selection
|
||||
// for any element type.
|
||||
function select<GElement extends d3Types.BaseType, OldDatum>(
|
||||
node: GElement | null | undefined,
|
||||
): d3Types.Selection<GElement, OldDatum, null, undefined>
|
||||
|
||||
// These type parameters are present on the original type.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ScaleSequential<Output, Unknown = never> {
|
||||
// This field exists in the code but not in the typings.
|
||||
ticks(): number[]
|
||||
}
|
||||
}
|
||||
|
||||
import {} from 'ag-grid-community'
|
||||
|
||||
declare module 'ag-grid-community' {
|
||||
// These type parameters are present on the original type.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColDef<TData, TValue> {
|
||||
/** Custom user-defined value. */
|
||||
manuallySized: boolean
|
||||
}
|
||||
|
||||
// These type parameters are present on the original type.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface AbstractColDef<TData, TValue> {
|
||||
// This field exists in the code but not in the typings.
|
||||
field: string
|
||||
}
|
||||
}
|
280
app/gui2/public/visualizations/events.ts
Normal file
@ -0,0 +1,280 @@
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
proxyRefs,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect,
|
||||
type Ref,
|
||||
type WatchSource,
|
||||
} from 'vue'
|
||||
|
||||
/**
|
||||
* Add an event listener for the duration of the component's lifetime.
|
||||
* @param target element on which to register the event
|
||||
* @param event name of event to register
|
||||
* @param handler event handler
|
||||
*/
|
||||
export function useEvent<K extends keyof DocumentEventMap>(
|
||||
target: Document,
|
||||
event: K,
|
||||
handler: (e: DocumentEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEvent<K extends keyof WindowEventMap>(
|
||||
target: Window,
|
||||
event: K,
|
||||
handler: (e: WindowEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEvent<K extends keyof ElementEventMap>(
|
||||
target: Element,
|
||||
event: K,
|
||||
handler: (event: ElementEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEvent(
|
||||
target: EventTarget,
|
||||
event: string,
|
||||
handler: (event: unknown) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void {
|
||||
onMounted(() => {
|
||||
target.addEventListener(event, handler, options)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
target.removeEventListener(event, handler, options)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event listener for the duration of condition being true.
|
||||
* @param target element on which to register the event
|
||||
* @param condition the condition that determines if event is bound
|
||||
* @param event name of event to register
|
||||
* @param handler event handler
|
||||
*/
|
||||
export function useEventConditional<K extends keyof DocumentEventMap>(
|
||||
target: Document,
|
||||
event: K,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (e: DocumentEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEventConditional<K extends keyof WindowEventMap>(
|
||||
target: Window,
|
||||
event: K,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (e: WindowEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEventConditional<K extends keyof ElementEventMap>(
|
||||
target: Element,
|
||||
event: K,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (event: ElementEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEventConditional(
|
||||
target: EventTarget,
|
||||
event: string,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (event: unknown) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEventConditional(
|
||||
target: EventTarget,
|
||||
event: string,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (event: unknown) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void {
|
||||
watch(condition, (conditionMet, _, onCleanup) => {
|
||||
if (conditionMet) {
|
||||
target.addEventListener(event, handler, options)
|
||||
onCleanup(() => target.removeEventListener(event, handler, options))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DOM node size and keep it up to date.
|
||||
*
|
||||
* # Warning:
|
||||
* Updating DOM node layout based on values derived from their size can introduce unwanted feedback
|
||||
* loops across the script and layout reflow. Avoid doing that.
|
||||
*
|
||||
* @param elementRef DOM node to observe.
|
||||
* @returns Reactive value with the DOM node size.
|
||||
*/
|
||||
export function useResizeObserver(
|
||||
elementRef: Ref<Element | undefined | null>,
|
||||
useContentRect = true,
|
||||
): Ref<Size> {
|
||||
const sizeRef = shallowRef<Size>({ width: 0, height: 0 })
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
let rect: Size | null = null
|
||||
for (const entry of entries) {
|
||||
if (entry.target === elementRef.value) {
|
||||
if (useContentRect) {
|
||||
rect = entry.contentRect
|
||||
} else {
|
||||
rect = entry.target.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rect != null) {
|
||||
sizeRef.value = { width: rect.width, height: rect.height }
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const element = elementRef.value
|
||||
if (element != null) {
|
||||
observer.observe(element)
|
||||
onCleanup(() => {
|
||||
if (elementRef.value != null) {
|
||||
observer.unobserve(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return sizeRef
|
||||
}
|
||||
|
||||
export interface EventPosition {
|
||||
/** The event position at the initialization of the drag. */
|
||||
initial: Position
|
||||
/** Absolute event position, equivalent to clientX/Y. */
|
||||
absolute: Position
|
||||
/** Event position relative to the initial position. Total movement of the drag so far. */
|
||||
relative: Position
|
||||
/** Difference of the event position since last event. */
|
||||
delta: Position
|
||||
}
|
||||
|
||||
type PointerEventType = 'start' | 'move' | 'stop'
|
||||
|
||||
/**
|
||||
* A mask of all available pointer buttons. The values are compatible with DOM's `PointerEvent.buttons` value. The mask values
|
||||
* can be ORed together to create a mask of multiple buttons.
|
||||
*/
|
||||
export const enum PointerButtonMask {
|
||||
/** No buttons are pressed. */
|
||||
Empty = 0,
|
||||
/** Main mouse button, usually left. */
|
||||
Main = 1,
|
||||
/** Secondary mouse button, usually right. */
|
||||
Secondary = 2,
|
||||
/** Auxiliary mouse button, usually middle or wheel press. */
|
||||
Auxiliary = 4,
|
||||
/** Additional fourth mouse button, usually assigned to "browser back" action. */
|
||||
ExtBack = 8,
|
||||
/** Additional fifth mouse button, usually assigned to "browser forward" action. */
|
||||
ExtForward = 16,
|
||||
}
|
||||
|
||||
/**
|
||||
* Register for a pointer dragging events.
|
||||
*
|
||||
* @param handler callback on any pointer event
|
||||
* @param requiredButtonMask declare which buttons to look for. The value represents a `PointerEvent.buttons` mask.
|
||||
* @returns
|
||||
*/
|
||||
export function usePointer(
|
||||
handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void,
|
||||
requiredButtonMask: number = PointerButtonMask.Main,
|
||||
) {
|
||||
const trackedPointer: Ref<number | null> = ref(null)
|
||||
let trackedElement: Element | null = null
|
||||
let initialGrabPos: Position | null = null
|
||||
let lastPos: Position | null = null
|
||||
|
||||
const isTracking = () => trackedPointer.value != null
|
||||
|
||||
function doStop(e: PointerEvent) {
|
||||
if (trackedElement != null && trackedPointer.value != null) {
|
||||
trackedElement.releasePointerCapture(trackedPointer.value)
|
||||
}
|
||||
|
||||
trackedPointer.value = null
|
||||
|
||||
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||
handler(computePosition(e, initialGrabPos, lastPos), e, 'stop')
|
||||
lastPos = null
|
||||
trackedElement = null
|
||||
}
|
||||
}
|
||||
|
||||
function doMove(e: PointerEvent) {
|
||||
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||
handler(computePosition(e, initialGrabPos, lastPos), e, 'move')
|
||||
lastPos = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
}
|
||||
|
||||
useEventConditional(window, 'pointerup', isTracking, (e: PointerEvent) => {
|
||||
if (trackedPointer.value === e.pointerId) {
|
||||
e.preventDefault()
|
||||
doStop(e)
|
||||
}
|
||||
})
|
||||
|
||||
useEventConditional(window, 'pointermove', isTracking, (e: PointerEvent) => {
|
||||
if (trackedPointer.value === e.pointerId) {
|
||||
e.preventDefault()
|
||||
// handle release of all masked buttons as stop
|
||||
if ((e.buttons & requiredButtonMask) != 0) {
|
||||
doMove(e)
|
||||
} else {
|
||||
doStop(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const events = {
|
||||
pointerdown(e: PointerEvent) {
|
||||
// pointers should not respond to unmasked mouse buttons
|
||||
if ((e.buttons & requiredButtonMask) == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (trackedPointer.value == null && e.currentTarget instanceof Element) {
|
||||
e.preventDefault()
|
||||
trackedPointer.value = e.pointerId
|
||||
trackedElement = e.currentTarget
|
||||
trackedElement.setPointerCapture(e.pointerId)
|
||||
initialGrabPos = { x: e.clientX, y: e.clientY }
|
||||
lastPos = initialGrabPos
|
||||
handler(computePosition(e, initialGrabPos, lastPos), e, 'start')
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return proxyRefs({
|
||||
events,
|
||||
dragging: computed(() => trackedPointer.value != null),
|
||||
})
|
||||
}
|
||||
|
||||
function computePosition(event: PointerEvent, initial: Position, last: Position): EventPosition {
|
||||
return {
|
||||
initial,
|
||||
absolute: { x: event.clientX, y: event.clientY },
|
||||
relative: { x: event.clientX - initial.x, y: event.clientY - initial.y },
|
||||
delta: { x: event.clientX - last.x, y: event.clientY - last.y },
|
||||
}
|
||||
}
|
7
app/gui2/public/visualizations/icons/find.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.8">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11 6.5C11 8.98528 8.98528 11 6.5 11C4.01472 11 2 8.98528 2 6.5C2 4.01472 4.01472 2 6.5 2C8.98528 2 11 4.01472 11 6.5ZM9.91218 12.0334C8.9204 12.6463 7.7515 13 6.5 13C2.91015 13 0 10.0899 0 6.5C0 2.91015 2.91015 0 6.5 0C10.0899 0 13 2.91015 13 6.5C13 7.75147 12.6463 8.92033 12.0335 9.91209L15.5601 13.4387C16.1458 14.0244 16.1458 14.9742 15.5601 15.56C14.9743 16.1458 14.0245 16.1458 13.4387 15.56L9.91218 12.0334Z"
|
||||
fill="black" fill-opacity="0.6" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 646 B |
@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="3" width="2" height="10" fill="black" fill-opacity="0.6" />
|
||||
<rect opacity="0.6" x="4" y="7" width="2" height="6" fill="black" fill-opacity="0.6" />
|
||||
<rect opacity="0.6" x="7" y="7" width="2" height="6" fill="black" fill-opacity="0.6" />
|
||||
<rect opacity="0.6" x="13" y="7" width="2" height="6" fill="black" fill-opacity="0.6" />
|
||||
<rect x="10" y="3" width="2" height="10" fill="black" fill-opacity="0.6" />
|
||||
</svg>
|
After Width: | Height: | Size: 528 B |
5
app/gui2/public/visualizations/icons/geo_map_pin.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M12.2426 11.2426C13.0818 10.4035 13.6532 9.33443 13.8847 8.17054C14.1162 7.00665 13.9974 5.80025 13.5433 4.7039C13.0892 3.60754 12.3201 2.67047 11.3334 2.01118C10.3467 1.35189 9.18669 1 8 1C6.81331 1 5.65328 1.35189 4.66658 2.01118C3.67989 2.67047 2.91085 3.60754 2.45673 4.7039C2.0026 5.80025 1.88378 7.00665 2.11529 8.17054C2.3468 9.33443 2.91825 10.4035 3.75736 11.2426L3.76 11.24L8 15.48L12.24 11.24L12.2426 11.2426ZM8 9.5C9.38071 9.5 10.5 8.38071 10.5 7C10.5 5.61929 9.38071 4.5 8 4.5C6.61929 4.5 5.5 5.61929 5.5 7C5.5 8.38071 6.61929 9.5 8 9.5Z"
|
||||
fill="black" fill-opacity="0.6" />
|
||||
</svg>
|
After Width: | Height: | Size: 748 B |
5
app/gui2/public/visualizations/icons/path2.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M3.73244 4C3.38663 4.5978 2.74028 5 2 5C0.895431 5 0 4.10457 0 3C0 1.89543 0.895431 1 2 1C2.74028 1 3.38663 1.4022 3.73244 2H10C10.9283 2 11.8185 2.36875 12.4749 3.02513C13.1313 3.6815 13.5 4.57174 13.5 5.5C13.5 6.42826 13.1313 7.3185 12.4749 7.97487C11.8185 8.63125 10.9283 9 10 9H7C6.17158 9 5.5 9.67157 5.5 10.5C5.5 11.3284 6.17157 12 7 12H12.2676C12.6134 11.4022 13.2597 11 14 11C15.1046 11 16 11.8954 16 13C16 14.1046 15.1046 15 14 15C13.2597 15 12.6134 14.5978 12.2676 14H7C6.07174 14 5.1815 13.6313 4.52513 12.9749C3.86875 12.3185 3.5 11.4283 3.5 10.5C3.5 9.57174 3.86875 8.6815 4.52513 8.02513C5.1815 7.36875 6.07174 7 7 7L10 7C10.8284 7 11.5 6.32843 11.5 5.5C11.5 4.67158 10.8284 4.00001 10 4H3.73244Z"
|
||||
fill="black" fill-opacity="0.6" />
|
||||
</svg>
|
After Width: | Height: | Size: 908 B |
5
app/gui2/public/visualizations/icons/show_all.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M6 0H2C0.895431 0 0 0.895431 0 2V6H2V2H6V0ZM10 2V0H14C15.1046 0 16 0.895431 16 2V6H14V2H10ZM10 14H14V10H16V14C16 15.1046 15.1046 16 14 16H10V14ZM2 10V14H6V16H2C0.895431 16 0 15.1046 0 14V10H2Z"
|
||||
fill="black" fill-opacity="0.6" />
|
||||
</svg>
|
After Width: | Height: | Size: 390 B |
25
app/gui2/public/visualizations/measurement.ts
Normal file
@ -0,0 +1,25 @@
|
||||
function error(message: string): never {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
let _measureContext: CanvasRenderingContext2D | undefined
|
||||
function getMeasureContext() {
|
||||
return (_measureContext ??=
|
||||
document.createElement('canvas').getContext('2d') ?? error('Could not get canvas 2D context.'))
|
||||
}
|
||||
|
||||
/** Helper function to get text width to make sure that labels on the x axis do not overlap,
|
||||
* and keeps it readable. */
|
||||
export function getTextWidth(
|
||||
text: string | null | undefined,
|
||||
fontSize = '11.5px',
|
||||
fontFamily = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
) {
|
||||
if (text == null) {
|
||||
return 0
|
||||
}
|
||||
const context = getMeasureContext()
|
||||
context.font = `${fontSize} ${fontFamily}`
|
||||
const metrics = context.measureText(' ' + text)
|
||||
return metrics.width
|
||||
}
|
44
app/gui2/public/visualizations/template.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
export const name = '<name here>'
|
||||
export const inputType = '<allowed input type(s) here>'
|
||||
|
||||
interface Data {
|
||||
dataType: 'here'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import VisualizationContainer from 'builtins/VisualizationContainer.vue'
|
||||
// Optional: add your own external dependencies. The @ts-expect-error is required because TypeScript
|
||||
// does not allow HTTP imports.
|
||||
// @ts-expect-error
|
||||
import dependency from 'http://<js dependency here>'
|
||||
|
||||
const props = defineProps<{
|
||||
data: Data
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
// Optional:
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
// Optional:
|
||||
onMounted(() => {
|
||||
emit('update:preprocessor', '<module path here>', '<method name here>', '<optional args here>')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer>
|
||||
<!-- <content here> -->
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Optional */
|
||||
@import url('<style>');
|
||||
@import url('<dependencies>');
|
||||
@import url('<here>');
|
||||
</style>
|
@ -1,5 +1,6 @@
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as random from 'lib0/random.js'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
|
||||
@ -90,11 +91,11 @@ export class DistributedModule {
|
||||
|
||||
insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId {
|
||||
const range = [offset, offset + content.length]
|
||||
const newId = crypto.randomUUID() as ExprId
|
||||
const newId = random.uuidv4() as ExprId
|
||||
this.doc.transact(() => {
|
||||
this.contents.insert(offset, content + '\n')
|
||||
const start = Y.createRelativePositionFromTypeIndex(this.contents, range[0])
|
||||
const end = Y.createRelativePositionFromTypeIndex(this.contents, range[1])
|
||||
const start = Y.createRelativePositionFromTypeIndex(this.contents, range[0]!)
|
||||
const end = Y.createRelativePositionFromTypeIndex(this.contents, range[1]!)
|
||||
this.idMap.set(newId, encodeRange([start, end]))
|
||||
this.metadata.set(newId, meta)
|
||||
})
|
||||
@ -208,7 +209,7 @@ export class IdMap {
|
||||
this.accessed.add(val)
|
||||
return val
|
||||
} else {
|
||||
const newId = crypto.randomUUID() as ExprId
|
||||
const newId = random.uuidv4() as ExprId
|
||||
this.rangeToExpr.set(key, newId)
|
||||
this.accessed.add(newId)
|
||||
return newId
|
||||
|
@ -34,25 +34,26 @@
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--color-text-light: rgba(255, 255, 255, 0.7);
|
||||
|
||||
--color-widget: rgba(255, 255, 255, 0.12);
|
||||
--color-widget-selected: rgba(255, 255, 255, 0.58);
|
||||
|
||||
--color-port-connected: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--color-frame-bg: rgba(255, 255, 255, 0.3);
|
||||
|
||||
--color-dim: rgba(0, 0, 0, 0.25);
|
||||
--color-app-bg: rgba(255 255 255 / 0.8);
|
||||
--color-menu-entry-hover-bg: rgba(0 0 0 / 0.1);
|
||||
--color-visualization-bg: rgb(255 242 242);
|
||||
--color-dim: rgba(0 0 0 / 0.25);
|
||||
--color-frame-bg: rgba(255 255 255 / 0.3);
|
||||
--color-widget: rgba(255 255 255 / 0.12);
|
||||
--color-widget-selected: rgba(255 255 255 / 0.58);
|
||||
--color-port-connected: rgba(255 255 255 / 0.15);
|
||||
}
|
||||
|
||||
/* non-color variables */
|
||||
:root {
|
||||
/* The z-index of fullscreen elements that should display over the entire GUI. */
|
||||
--z-fullscreen: 1;
|
||||
--blur-app-bg: blur(64px);
|
||||
--disabled-opacity: 40%;
|
||||
|
||||
/* A `border-radius` higher than all possible element sizes.
|
||||
* A `border-radius` of 100% does not work because the element becomes an ellipse. */
|
||||
--radius-full: 9999px;
|
||||
|
||||
--radius-default: 16px;
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@ -66,8 +67,6 @@
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
/* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */
|
||||
background: #e4d4be;
|
||||
color: var(--color-text);
|
||||
/* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */
|
||||
background: #e4d4be;
|
||||
@ -104,10 +103,6 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@ -115,3 +110,44 @@ body {
|
||||
.button.disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Scrollbar style definitions for textual visualizations which need support for scrolling.
|
||||
*
|
||||
* The 11px width/height (depending on scrollbar orientation)
|
||||
* is set so that it resembles macOS default scrollbar.
|
||||
*/
|
||||
|
||||
.scrollable {
|
||||
scrollbar-color: rgba(190 190 190 / 50%) transparent;
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar-track {
|
||||
-webkit-box-shadow: none;
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar:vertical {
|
||||
width: 11px;
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar:horizontal {
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar-thumb {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(220, 220, 220, 0.5);
|
||||
background-color: rgba(190, 190, 190, 0.5);
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar-corner {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar-button {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 99 KiB |
@ -1,8 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.8">
|
||||
<g opacity="0.4">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 3L8 7L10 7L10 14L12 14L12 7L14 7L11 3Z" fill="#111827" />
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 14L8 10L6 10L6 3L4 3L4 10L2 10L5 14Z" fill="#111827" />
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 389 B |
87
app/gui2/src/components/CircularMenu.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isAutoEvaluationDisabled: boolean
|
||||
isDocsVisible: boolean
|
||||
isVisualizationVisible: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:isAutoEvaluationDisabled': [isAutoEvaluationDisabled: boolean]
|
||||
'update:isDocsVisible': [isDocsVisible: boolean]
|
||||
'update:isVisualizationVisible': [isVisualizationVisible: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="CircularMenu">
|
||||
<div class="background"></div>
|
||||
<ToggleIcon
|
||||
icon="no_auto_replay"
|
||||
class="icon-container button no-auto-evaluate-button"
|
||||
:model-value="isAutoEvaluationDisabled"
|
||||
@update:modelValue="emit('update:isAutoEvaluationDisabled', $event)"
|
||||
/>
|
||||
<ToggleIcon
|
||||
icon="docs"
|
||||
class="icon-container button docs-button"
|
||||
:model-value="isDocsVisible"
|
||||
@update:modelValue="emit('update:isDocsVisible', $event)"
|
||||
/>
|
||||
<ToggleIcon
|
||||
icon="eye"
|
||||
class="icon-container button visualization-button"
|
||||
:model-value="isVisualizationVisible"
|
||||
@update:modelValue="emit('update:isVisualizationVisible', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.CircularMenu {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
left: -36px;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
}
|
||||
|
||||
.CircularMenu > .background {
|
||||
position: absolute;
|
||||
clip-path: path('m0 16a52 52 0 0 0 52 52a16 16 0 0 0 0 -32a20 20 0 0 1-20-20a16 16 0 0 0-32 0');
|
||||
background: var(--color-app-bg);
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: inline-flex;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
opacity: 30%;
|
||||
}
|
||||
|
||||
.toggledOn {
|
||||
opacity: unset;
|
||||
}
|
||||
|
||||
.no-auto-evaluate-button {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.docs-button {
|
||||
position: absolute;
|
||||
left: 18.54px;
|
||||
top: 33.46px;
|
||||
}
|
||||
|
||||
.visualization-button {
|
||||
position: absolute;
|
||||
left: 44px;
|
||||
top: 44px;
|
||||
}
|
||||
</style>
|
@ -14,7 +14,7 @@ export interface Component {
|
||||
icon: string
|
||||
label: string
|
||||
match: MatchResult
|
||||
group?: number
|
||||
group?: number | undefined
|
||||
}
|
||||
|
||||
export function labelOfEntry(entry: SuggestionEntry, filtering: Filtering) {
|
||||
@ -55,7 +55,7 @@ export function makeComponentList(db: SuggestionDb, filtering: Filtering): Compo
|
||||
}
|
||||
const matched: MatchedSuggestion[] = Array.from(matchSuggestions())
|
||||
matched.sort(compareSuggestions)
|
||||
return Array.from(matched, ({ id, entry, match }) => {
|
||||
return Array.from(matched, ({ id, entry, match }): Component => {
|
||||
return {
|
||||
suggestionId: id,
|
||||
icon: entry.iconName ?? 'marketplace',
|
||||
|
@ -183,7 +183,7 @@ class FilteringQualifiedName {
|
||||
*/
|
||||
export class Filtering {
|
||||
pattern?: FilteringWithPattern
|
||||
selfType?: QualifiedName
|
||||
selfType?: QualifiedName | undefined
|
||||
qualifiedName?: FilteringQualifiedName
|
||||
showUnstable: boolean = false
|
||||
showLocal: boolean = false
|
||||
|
@ -15,11 +15,11 @@ const props = defineProps<{
|
||||
const edgePath = computed(() => {
|
||||
let edge = props.edge
|
||||
const targetNodeId = props.exprNodes.get(edge.target)
|
||||
if (targetNodeId == null) return
|
||||
if (targetNodeId == null) return ''
|
||||
let sourceNodeRect = props.nodeRects.get(edge.source)
|
||||
let targetNodeRect = props.nodeRects.get(targetNodeId)
|
||||
let targetRect = props.exprRects.get(edge.target)
|
||||
if (sourceNodeRect == null || targetRect == null || targetNodeRect == null) return
|
||||
if (sourceNodeRect == null || targetRect == null || targetNodeRect == null) return ''
|
||||
let sourcePos = sourceNodeRect.center()
|
||||
let targetPos = targetRect.center().add(targetNodeRect.pos)
|
||||
|
||||
|
@ -1,12 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUpdated, reactive, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||
|
||||
import CircularMenu from '@/components/CircularMenu.vue'
|
||||
import NodeSpan from '@/components/NodeSpan.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import {
|
||||
provideVisualizationConfig,
|
||||
type VisualizationConfig,
|
||||
} from '@/providers/visualizationConfig'
|
||||
import type { Node } from '@/stores/graph'
|
||||
import { Rect } from '@/stores/rect'
|
||||
import { usePointer, useResizeObserver } from '@/util/events'
|
||||
import { useVisualizationStore, type Visualization } from '@/stores/visualization'
|
||||
import { useDocumentEvent, usePointer, useResizeObserver } from '@/util/events'
|
||||
import type { Vec2 } from '@/util/vec2'
|
||||
import type { ContentRange, ExprId } from 'shared/yjsModel'
|
||||
import { computed, onUpdated, reactive, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
node: Node
|
||||
@ -20,6 +27,8 @@ const emit = defineEmits<{
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const visualizationStore = useVisualizationStore()
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
const nodeSize = useResizeObserver(rootNode)
|
||||
const editableRootNode = ref<HTMLElement>()
|
||||
@ -186,39 +195,40 @@ function saveSelections() {
|
||||
}
|
||||
|
||||
onUpdated(() => {
|
||||
if (selectionToRecover != null && editableRootNode.value != null) {
|
||||
const saved = selectionToRecover
|
||||
const root = editableRootNode.value
|
||||
selectionToRecover = null
|
||||
const selection = window.getSelection()
|
||||
if (selection == null) return
|
||||
const root = editableRootNode.value
|
||||
|
||||
function findTextNodeAtOffset(offset: number | null): { node: Text; offset: number } | null {
|
||||
if (offset == null) return null
|
||||
for (let textSpan of root.querySelectorAll<HTMLSpanElement>('span[data-span-start]')) {
|
||||
if (textSpan.children.length > 0) continue
|
||||
const start = parseInt(textSpan.dataset.spanStart ?? '0')
|
||||
const text = textSpan.textContent ?? ''
|
||||
const end = start + text.length
|
||||
if (start <= offset && offset <= end) {
|
||||
let remainingOffset = offset - start
|
||||
for (let node of textSpan.childNodes) {
|
||||
if (node instanceof Text) {
|
||||
let length = node.data.length
|
||||
if (remainingOffset > length) {
|
||||
remainingOffset -= length
|
||||
} else {
|
||||
return {
|
||||
node,
|
||||
offset: remainingOffset,
|
||||
}
|
||||
function findTextNodeAtOffset(offset: number | null): { node: Text; offset: number } | null {
|
||||
if (offset == null) return null
|
||||
for (let textSpan of root?.querySelectorAll<HTMLSpanElement>('span[data-span-start]') ?? []) {
|
||||
if (textSpan.children.length > 0) continue
|
||||
const start = parseInt(textSpan.dataset.spanStart ?? '0')
|
||||
const text = textSpan.textContent ?? ''
|
||||
const end = start + text.length
|
||||
if (start <= offset && offset <= end) {
|
||||
let remainingOffset = offset - start
|
||||
for (let node of textSpan.childNodes) {
|
||||
if (node instanceof Text) {
|
||||
let length = node.data.length
|
||||
if (remainingOffset > length) {
|
||||
remainingOffset -= length
|
||||
} else {
|
||||
return {
|
||||
node,
|
||||
offset: remainingOffset,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectionToRecover != null && editableRootNode.value != null) {
|
||||
const saved = selectionToRecover
|
||||
selectionToRecover = null
|
||||
const selection = window.getSelection()
|
||||
if (selection == null) return
|
||||
|
||||
for (let range of saved.ranges) {
|
||||
const start = findTextNodeAtOffset(range[0])
|
||||
@ -251,40 +261,167 @@ function handleClick(e: PointerEvent) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
const isCircularMenuVisible = ref(false)
|
||||
const isAutoEvaluationDisabled = ref(false)
|
||||
const isDocsVisible = ref(false)
|
||||
const isVisualizationVisible = ref(false)
|
||||
|
||||
const visualizationType = ref('Scatterplot')
|
||||
const visualization = shallowRef<Visualization>()
|
||||
|
||||
const queuedVisualizationData = computed<{}>(() =>
|
||||
visualizationStore.sampleData(visualizationType.value),
|
||||
)
|
||||
const visualizationData = ref<{}>({})
|
||||
|
||||
function isInputEvent(event: Event): event is Event & { target: HTMLElement } {
|
||||
return (
|
||||
!(event.target instanceof HTMLElement) ||
|
||||
!rootNode.value?.contains(event.target) ||
|
||||
event.target.isContentEditable ||
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
}
|
||||
|
||||
const visualizationConfig = ref<VisualizationConfig>({
|
||||
fullscreen: false,
|
||||
types: visualizationStore.types,
|
||||
width: null,
|
||||
height: 150, // FIXME:
|
||||
hide() {
|
||||
isVisualizationVisible.value = false
|
||||
},
|
||||
updateType(type) {
|
||||
visualizationType.value = type
|
||||
},
|
||||
isCircularMenuVisible: isCircularMenuVisible.value,
|
||||
get nodeSize() {
|
||||
return nodeSize.value
|
||||
},
|
||||
})
|
||||
provideVisualizationConfig(visualizationConfig)
|
||||
|
||||
useDocumentEvent('keydown', (event) => {
|
||||
if (isInputEvent(event)) {
|
||||
return
|
||||
}
|
||||
if (event.key === ' ') {
|
||||
if (event.shiftKey) {
|
||||
if (isVisualizationVisible.value) {
|
||||
visualizationConfig.value.fullscreen = !visualizationConfig.value.fullscreen
|
||||
} else {
|
||||
isVisualizationVisible.value = true
|
||||
visualizationConfig.value.fullscreen = true
|
||||
}
|
||||
} else {
|
||||
isVisualizationVisible.value = !isVisualizationVisible.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(async (onCleanup) => {
|
||||
if (isVisualizationVisible.value) {
|
||||
let shouldSwitchVisualization = true
|
||||
onCleanup(() => {
|
||||
shouldSwitchVisualization = false
|
||||
})
|
||||
const component = await visualizationStore.get(visualizationType.value)
|
||||
if (shouldSwitchVisualization) {
|
||||
visualization.value = component
|
||||
visualizationData.value = queuedVisualizationData.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function onBlur(event: FocusEvent) {
|
||||
if (!(event.relatedTarget instanceof Node) || !rootNode.value?.contains(event.relatedTarget)) {
|
||||
isCircularMenuVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onExpressionClick(event: Event) {
|
||||
if (isInputEvent(event)) {
|
||||
return
|
||||
}
|
||||
rootNode.value?.focus()
|
||||
isCircularMenuVisible.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [isAutoEvaluationDisabled.value, isDocsVisible.value, isVisualizationVisible.value],
|
||||
() => {
|
||||
rootNode.value?.focus()
|
||||
},
|
||||
)
|
||||
|
||||
function updatePreprocessor(module: string, method: string, ...args: string[]) {
|
||||
console.log(
|
||||
`preprocessor changed. node id: ${
|
||||
props.node.rootSpan.id
|
||||
} module: ${module}, method: ${method}, args: [${args.join(', ')}]`,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootNode"
|
||||
class="Node"
|
||||
:tabindex="-1"
|
||||
class="GraphNode"
|
||||
:style="{ transform }"
|
||||
:class="{ dragging: dragPointer.dragging }"
|
||||
v-on="dragPointer.events"
|
||||
@focus="isCircularMenuVisible = true"
|
||||
@blur="onBlur"
|
||||
>
|
||||
<SvgIcon class="icon" name="number_input" @pointerdown="handleClick"></SvgIcon>
|
||||
<div class="binding" @pointerdown.stop>{{ node.binding }}</div>
|
||||
<div
|
||||
ref="editableRootNode"
|
||||
class="editable"
|
||||
contenteditable
|
||||
spellcheck="false"
|
||||
@beforeinput="editContent"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<NodeSpan
|
||||
:content="node.content"
|
||||
:span="node.rootSpan"
|
||||
:offset="0"
|
||||
@updateExprRect="updateExprRect"
|
||||
/>
|
||||
<div class="binding" @pointerdown.stop>
|
||||
{{ node.binding }}
|
||||
</div>
|
||||
<CircularMenu
|
||||
v-if="isCircularMenuVisible"
|
||||
v-model:is-auto-evaluation-disabled="isAutoEvaluationDisabled"
|
||||
v-model:is-docs-visible="isDocsVisible"
|
||||
v-model:is-visualization-visible="isVisualizationVisible"
|
||||
/>
|
||||
<component
|
||||
:is="visualization"
|
||||
v-if="isVisualizationVisible && visualization"
|
||||
:data="visualizationData"
|
||||
@update:preprocessor="updatePreprocessor"
|
||||
@update:type="visualizationType = $event"
|
||||
/>
|
||||
<div class="node" v-on="dragPointer.events" @click.stop="onExpressionClick">
|
||||
<SvgIcon class="icon" name="number_input" @pointerdown="handleClick"></SvgIcon>
|
||||
<div
|
||||
ref="editableRootNode"
|
||||
class="editable"
|
||||
contenteditable
|
||||
spellcheck="false"
|
||||
@beforeinput="editContent"
|
||||
@focus="isCircularMenuVisible = true"
|
||||
@blur="onBlur"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<NodeSpan
|
||||
:content="node.content"
|
||||
:span="node.rootSpan"
|
||||
:offset="0"
|
||||
@updateExprRect="updateExprRect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.Node {
|
||||
.GraphNode {
|
||||
color: red;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
@ -295,11 +432,12 @@ function handleClick(e: PointerEvent) {
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
background: #222;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.binding {
|
||||
user-select: none;
|
||||
margin-right: 10px;
|
||||
color: black;
|
||||
position: absolute;
|
||||
@ -310,17 +448,34 @@ function handleClick(e: PointerEvent) {
|
||||
|
||||
.editable {
|
||||
outline: none;
|
||||
height: 24px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: white;
|
||||
cursor: grab;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.Node.dragging,
|
||||
.Node.dragging .icon {
|
||||
.GraphNode.dragging,
|
||||
.GraphNode.dragging .icon {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.visualization {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 4px;
|
||||
background: #222;
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
|
@ -72,7 +72,7 @@ watch(exprRect, (rect) => {
|
||||
:key="child.id"
|
||||
:content="props.content"
|
||||
:span="child"
|
||||
:offset="childOffsets[index]"
|
||||
:offset="childOffsets[index]!"
|
||||
@updateExprRect="(id, rect) => emit('updateExprRect', id, rect)" /></template
|
||||
><template v-else>{{ exprPart }}</template></span
|
||||
>
|
||||
|
315
app/gui2/src/components/VisualizationContainer.vue
Normal file
@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import VisualizationSelector from '@/components/VisualizationSelector.vue'
|
||||
|
||||
import { PointerButtonMask, usePointer } from '@/util/events'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useVisualizationConfig } from '../providers/visualizationConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
/** If true, the visualization should be `overflow: visible` instead of `overflow: hidden`. */
|
||||
overflow?: boolean
|
||||
/** If true, the visualization should display below the node background. */
|
||||
belowNode?: boolean
|
||||
/** If true, the visualization should display below the toolbar buttons. */
|
||||
belowToolbar?: boolean
|
||||
}>()
|
||||
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
const isSelectorVisible = ref(false)
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
if (
|
||||
event.currentTarget instanceof Element &&
|
||||
(event.currentTarget.scrollWidth > event.currentTarget.clientWidth ||
|
||||
event.currentTarget.scrollHeight > event.currentTarget.clientHeight)
|
||||
) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
const contentNode = ref<HTMLElement>()
|
||||
|
||||
const resizeRight = usePointer((pos, _, type) => {
|
||||
if (type !== 'move' || pos.delta.x === 0) {
|
||||
return
|
||||
}
|
||||
const width = pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)
|
||||
config.value.width = Math.max(config.value.nodeSize.x, width)
|
||||
}, PointerButtonMask.Main)
|
||||
|
||||
const resizeBottom = usePointer((pos, _, type) => {
|
||||
if (type !== 'move' || pos.delta.y === 0) {
|
||||
return
|
||||
}
|
||||
const height = pos.absolute.y - (contentNode.value?.getBoundingClientRect().top ?? 0)
|
||||
config.value.height = Math.max(0, height)
|
||||
}, PointerButtonMask.Main)
|
||||
|
||||
const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
if (type !== 'move') {
|
||||
return
|
||||
}
|
||||
if (pos.delta.x !== 0) {
|
||||
const width = pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)
|
||||
config.value.width = Math.max(config.value.nodeSize.x, width)
|
||||
}
|
||||
if (pos.delta.y !== 0) {
|
||||
const height = pos.absolute.y - (contentNode.value?.getBoundingClientRect().top ?? 0)
|
||||
config.value.height = Math.max(0, height)
|
||||
}
|
||||
}, PointerButtonMask.Main)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body" :disabled="!config.fullscreen">
|
||||
<div
|
||||
ref="rootNode"
|
||||
class="VisualizationContainer"
|
||||
:class="{
|
||||
fullscreen: config.fullscreen,
|
||||
'circular-menu-visible': config.isCircularMenuVisible,
|
||||
'below-node': belowNode,
|
||||
'below-toolbar': belowToolbar,
|
||||
}"
|
||||
:style="{
|
||||
'--color-visualization-bg': config.background,
|
||||
}"
|
||||
>
|
||||
<div class="resizer-right" v-on="resizeRight.events"></div>
|
||||
<div class="resizer-bottom" v-on="resizeBottom.events"></div>
|
||||
<div class="resizer-bottom-right" v-on="resizeBottomRight.events"></div>
|
||||
<div
|
||||
ref="contentNode"
|
||||
class="content scrollable"
|
||||
:class="{ overflow }"
|
||||
:style="{
|
||||
width: config.fullscreen
|
||||
? undefined
|
||||
: `${Math.max(config.width ?? 0, config.nodeSize.x)}px`,
|
||||
height: config.fullscreen ? undefined : `${config.height}px`,
|
||||
}"
|
||||
@wheel.passive="onWheel"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="toolbars">
|
||||
<div
|
||||
:class="{
|
||||
toolbar: true,
|
||||
invisible: config.isCircularMenuVisible,
|
||||
hidden: config.fullscreen,
|
||||
}"
|
||||
>
|
||||
<div class="background"></div>
|
||||
<button class="image-button active" @click="config.hide()">
|
||||
<SvgIcon class="icon" name="eye" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="background"></div>
|
||||
<button class="image-button active" @click="config.fullscreen = !config.fullscreen">
|
||||
<SvgIcon class="icon" :name="config.fullscreen ? 'exit_fullscreen' : 'fullscreen'" />
|
||||
</button>
|
||||
<div class="icon-container">
|
||||
<button
|
||||
class="image-button active"
|
||||
@click.stop="isSelectorVisible = !isSelectorVisible"
|
||||
>
|
||||
<SvgIcon class="icon" name="compass" />
|
||||
</button>
|
||||
<VisualizationSelector
|
||||
v-if="isSelectorVisible"
|
||||
:types="config.types"
|
||||
@hide="isSelectorVisible = false"
|
||||
@update:type="
|
||||
(type) => {
|
||||
isSelectorVisible = false
|
||||
config.updateType(type)
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="background"></div>
|
||||
<slot name="toolbar"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.VisualizationContainer {
|
||||
background: var(--color-visualization-bg);
|
||||
position: absolute;
|
||||
min-width: 100%;
|
||||
width: min-content;
|
||||
color: var(--color-text);
|
||||
z-index: -1;
|
||||
border-radius: var(--radius-default);
|
||||
}
|
||||
|
||||
.VisualizationContainer.below-node {
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
.VisualizationContainer.below-toolbar {
|
||||
padding-top: 72px;
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen {
|
||||
z-index: var(--z-fullscreen);
|
||||
position: fixed;
|
||||
padding-top: 0;
|
||||
border-radius: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen.below-node {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen.below-toolbar {
|
||||
padding-top: 38px;
|
||||
}
|
||||
|
||||
.toolbars {
|
||||
transition-duration: 100ms;
|
||||
transition-property: padding-left;
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen .toolbars,
|
||||
.VisualizationContainer:not(.circular-menu-visible) .toolbars {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.content.overflow {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen .content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.toolbars {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
top: 36px;
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen .toolbars {
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
border-radius: var(--radius-full);
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
|
||||
> .background.background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-app-bg);
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar:not(:first-child):not(:has(> :nth-child(2))) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resizer-right {
|
||||
position: absolute;
|
||||
cursor: ew-resize;
|
||||
left: 100%;
|
||||
width: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.VisualizationContainer.below-node > .resizer-right {
|
||||
height: calc(100% - 36px);
|
||||
}
|
||||
|
||||
.VisualizationContainer.below-toolbar > .resizer-right {
|
||||
height: calc(100% - 72px);
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen.below-node > .resizer-right {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen.below-toolbar > .resizer-right {
|
||||
height: calc(100% - 38px);
|
||||
}
|
||||
|
||||
.resizer-bottom {
|
||||
position: absolute;
|
||||
cursor: ns-resize;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.resizer-bottom-right {
|
||||
position: absolute;
|
||||
cursor: nwse-resize;
|
||||
left: calc(100% - 8px);
|
||||
top: calc(100% - 8px);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.VisualizationContainer > .toolbars > .toolbar > * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-button {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
opacity: 30%;
|
||||
}
|
||||
|
||||
.image-button.active {
|
||||
cursor: pointer;
|
||||
opacity: unset;
|
||||
}
|
||||
|
||||
.image-button > * {
|
||||
vertical-align: top;
|
||||
}
|
||||
</style>
|
81
app/gui2/src/components/VisualizationSelector.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{ types: string[] }>()
|
||||
const emit = defineEmits<{ hide: []; 'update:type': [type: string] }>()
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (event.target instanceof Node && rootNode.value?.contains(event.target)) {
|
||||
return
|
||||
}
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootNode" class="VisualizationSelector">
|
||||
<div class="background"></div>
|
||||
<ul>
|
||||
<li
|
||||
v-for="type_ in types"
|
||||
:key="type_"
|
||||
@click="emit('update:type', type_)"
|
||||
v-text="type_"
|
||||
></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.VisualizationSelector {
|
||||
/* Required for it to show above Mapbox's information button. */
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
border-radius: 16px;
|
||||
top: 100%;
|
||||
margin-top: 12px;
|
||||
left: -12px;
|
||||
}
|
||||
|
||||
.VisualizationSelector > * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.VisualizationSelector > .background {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
background: var(--color-app-bg);
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
list-style-type: none;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
padding: 0 8px;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-menu-entry-hover-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
252
app/gui2/src/components/visualizations/HeatmapVisualization.vue
Normal file
@ -0,0 +1,252 @@
|
||||
<script lang="ts">
|
||||
export const name = 'Heatmap'
|
||||
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
|
||||
|
||||
type Data = HeatmapData | HeatmapArrayData | HeatmapJSONData | HeatmapUpdate
|
||||
|
||||
interface HeatmapData {
|
||||
update: undefined
|
||||
data: object[]
|
||||
json: undefined
|
||||
}
|
||||
|
||||
type HeatmapArrayData = object[] & {
|
||||
update: undefined
|
||||
data: undefined
|
||||
json: undefined
|
||||
}
|
||||
|
||||
interface HeatmapJSONData {
|
||||
update: undefined
|
||||
data: undefined
|
||||
json: object[]
|
||||
}
|
||||
|
||||
interface HeatmapUpdate {
|
||||
update: 'diff'
|
||||
data: object[] | undefined
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
index: number
|
||||
group: number
|
||||
variable: number
|
||||
value: number
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-redeclare
|
||||
declare var d3: typeof import('d3')
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watchPostEffect } from 'vue'
|
||||
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line no-redeclare
|
||||
import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7.8.5/+esm'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
import { useVisualizationConfig } from '@/providers/useVisualizationConfig.ts'
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
const MARGIN = { top: 20, right: 20, bottom: 20, left: 25 }
|
||||
|
||||
const data = computed(() => {
|
||||
if (props.data == null) {
|
||||
console.error('Heatmap was not passed any data.')
|
||||
return []
|
||||
} else if (props.data.update === 'diff') {
|
||||
if (props.data.data != null) {
|
||||
return props.data.data
|
||||
}
|
||||
} else if (props.data.data != null) {
|
||||
return props.data.data
|
||||
} else if (Array.isArray(props.data)) {
|
||||
return props.data
|
||||
} else if (props.data.json != null && Array.isArray(props.data.json)) {
|
||||
return props.data.json
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const containerNode = ref<HTMLElement>()
|
||||
const pointsNode = ref<SVGElement>()
|
||||
const xAxisNode = ref<SVGGElement>()
|
||||
const yAxisNode = ref<SVGGElement>()
|
||||
|
||||
const d3XAxis = computed(() => d3.select(xAxisNode.value))
|
||||
const d3YAxis = computed(() => d3.select(yAxisNode.value))
|
||||
|
||||
const d3Points = computed(() => d3.select(pointsNode.value))
|
||||
|
||||
const fill = computed(() =>
|
||||
d3
|
||||
.scaleSequential()
|
||||
.interpolator(d3.interpolateViridis)
|
||||
.domain([0, d3.max(buckets.value, (d) => d.value) ?? 1]),
|
||||
)
|
||||
|
||||
const width = ref(Math.max(config.value.width ?? 0, config.value.nodeSize.x))
|
||||
watchPostEffect(() => {
|
||||
width.value = config.value.fullscreen
|
||||
? containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
: Math.max(config.value.width ?? 0, config.value.nodeSize.x)
|
||||
})
|
||||
const height = ref(config.value.height ?? (config.value.nodeSize.x * 3) / 4)
|
||||
watchPostEffect(() => {
|
||||
height.value = config.value.fullscreen
|
||||
? containerNode.value?.parentElement?.clientHeight ?? 0
|
||||
: config.value.height ?? (config.value.nodeSize.x * 3) / 4
|
||||
})
|
||||
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))
|
||||
const boxHeight = computed(() => Math.max(0, height.value - MARGIN.top - MARGIN.bottom))
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:preprocessor', 'Standard.Visualization.Table.Visualization', 'prepare_visualization')
|
||||
})
|
||||
|
||||
const buckets = computed(() => {
|
||||
const newData = data.value
|
||||
let groups: number[] = []
|
||||
let variables: number[] = []
|
||||
let values: number[] = []
|
||||
if (newData.length != null && newData.length === 2 && Array.isArray(newData[1])) {
|
||||
const indices = Array.from(Array(newData[1].length).keys())
|
||||
groups = newData[0] as any
|
||||
variables = indices
|
||||
values = newData[1]
|
||||
} else if (!Array.isArray(newData[0])) {
|
||||
const indices = Array.from(Array(newData.length).keys())
|
||||
groups = indices
|
||||
variables = []
|
||||
values = newData as any
|
||||
} else if (newData.length != null && newData.length === 1 && Array.isArray(newData[0])) {
|
||||
const indices = Array.from(Array(newData[0].length).keys())
|
||||
groups = indices
|
||||
variables = []
|
||||
values = newData[0]
|
||||
} else {
|
||||
groups = newData[0] as any
|
||||
variables = newData[1] as any
|
||||
values = newData[2] as any
|
||||
}
|
||||
return Array.from<unknown, Bucket>({ length: groups.length }, (_, i) => ({
|
||||
index: i,
|
||||
group: groups[i]!,
|
||||
variable: variables[i]!,
|
||||
value: values[i]!,
|
||||
}))
|
||||
})
|
||||
const groups = computed(() => Array.from(new Set(Array.from(buckets.value, (p) => p.group))))
|
||||
const variables = computed(() => Array.from(new Set(Array.from(buckets.value, (p) => p.variable))))
|
||||
|
||||
const xScale = computed(() =>
|
||||
d3
|
||||
.scaleBand<number>()
|
||||
.padding(0.05)
|
||||
.range([0, boxWidth.value])
|
||||
.domain(buckets.value.map((d) => d.group)),
|
||||
)
|
||||
const yScale = computed(() =>
|
||||
d3
|
||||
.scaleBand<number>()
|
||||
.padding(0.05)
|
||||
.range([boxHeight.value, 0])
|
||||
.domain(buckets.value.map((d) => d.variable ?? 0)),
|
||||
)
|
||||
|
||||
const xAxis = computed(() => {
|
||||
const xMod = Math.max(1, Math.round(buckets.value.length / (boxWidth.value / 40)))
|
||||
const lastGroupIndex = groups.value.length - 1
|
||||
return d3
|
||||
.axisBottom(xScale.value)
|
||||
.tickSize(0)
|
||||
.tickValues(groups.value.filter((_, i) => i % xMod === 0 || i === lastGroupIndex))
|
||||
})
|
||||
|
||||
const yAxis = computed(() => {
|
||||
const yMod = Math.max(1, Math.round(buckets.value.length / (boxHeight.value / 20)))
|
||||
const lastVariableIndex = variables.value.length - 1
|
||||
return d3
|
||||
.axisLeft(yScale.value)
|
||||
.tickSize(0)
|
||||
.tickValues(variables.value.filter((_, i) => i % yMod === 0 || i === lastVariableIndex))
|
||||
})
|
||||
|
||||
// ==============
|
||||
// === Update ===
|
||||
// ==============
|
||||
|
||||
// === Update x axis ===
|
||||
|
||||
watchPostEffect(() => d3XAxis.value.call(xAxis.value))
|
||||
|
||||
// === Update y axis ===
|
||||
|
||||
watchPostEffect(() => d3YAxis.value.call(yAxis.value))
|
||||
|
||||
// === Update contents ===
|
||||
|
||||
watchPostEffect(() => {
|
||||
const buckets_ = buckets.value
|
||||
const xScale_ = xScale.value
|
||||
const yScale_ = yScale.value
|
||||
const fill_ = fill.value
|
||||
d3Points.value
|
||||
.selectAll('rect')
|
||||
.data(buckets_)
|
||||
.join((enter) =>
|
||||
enter
|
||||
.append('rect')
|
||||
.attr('rx', 4)
|
||||
.attr('ry', 4)
|
||||
.style('stroke-width', 4)
|
||||
.style('stroke', 'none')
|
||||
.style('opacity', 0.8)
|
||||
.style('fill', (d) => fill_(d.value)),
|
||||
)
|
||||
.attr('width', xScale_.bandwidth())
|
||||
.attr('height', yScale_.bandwidth())
|
||||
.attr('x', (d) => xScale_(d.group)!)
|
||||
.attr('y', (d) => yScale_(d.variable ?? 0)!)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<div ref="containerNode" class="HeatmapVisualization">
|
||||
<svg :width="width" :height="height">
|
||||
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">
|
||||
<g ref="xAxisNode" class="label label-x" :transform="`translate(0, ${boxHeight})`"></g>
|
||||
<g ref="yAxisNode" class="label label-y"></g>
|
||||
<g ref="pointsNode"></g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://fonts.cdnfonts.com/css/dejavu-sans-mono');
|
||||
|
||||
.HeatmapVisualization {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'DejaVu Sans Mono', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.HeatmapVisualization .label .domain {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,672 @@
|
||||
<script lang="ts">
|
||||
export const name = 'Histogram'
|
||||
export const inputType =
|
||||
'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector | Standard.Image.Data.Histogram.Histogram'
|
||||
|
||||
// eslint-disable-next-line no-redeclare
|
||||
declare const d3: typeof import('d3')
|
||||
|
||||
/**
|
||||
* A d3.js histogram visualization.
|
||||
*
|
||||
*
|
||||
* Data format (JSON):
|
||||
* {
|
||||
* "axis" : {
|
||||
* "x" : { "label" : "x-axis label", "scale" : "linear" },
|
||||
* "y" : { "label" : "y-axis label", "scale" : "logarithmic" },
|
||||
* },
|
||||
* "focus" : { "x" : 1.7, "y" : 2.1, "zoom" : 3.0 },
|
||||
* "color" : "rgb(1.0,0.0,0.0)",
|
||||
* "bins" : 10,
|
||||
* "data" : {
|
||||
* "values" : [0.1, 0.2, 0.1, 0.15, 0.7],
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
type Data = HistogramData | HistogramArrayData | HistogramJSONData | HistogramUpdate
|
||||
|
||||
interface HistogramInnerData {
|
||||
values: number[]
|
||||
bins: number[] | undefined
|
||||
}
|
||||
|
||||
interface HistogramData {
|
||||
update: undefined
|
||||
data: HistogramInnerData
|
||||
json: undefined
|
||||
axis: AxesConfiguration
|
||||
focus: Focus | undefined
|
||||
bins: number | undefined
|
||||
}
|
||||
|
||||
type HistogramArrayData = number[] & {
|
||||
update: undefined
|
||||
data: undefined
|
||||
json: undefined
|
||||
axis: undefined
|
||||
focus: undefined
|
||||
bins: undefined
|
||||
}
|
||||
|
||||
interface HistogramJSONData {
|
||||
update: undefined
|
||||
data: undefined
|
||||
json: number[]
|
||||
axis: undefined
|
||||
focus: undefined
|
||||
bins: undefined
|
||||
}
|
||||
|
||||
interface HistogramUpdate {
|
||||
update: 'diff'
|
||||
data: HistogramInnerData | undefined
|
||||
json: undefined
|
||||
axis: undefined
|
||||
focus: undefined
|
||||
bins: undefined
|
||||
}
|
||||
|
||||
interface Focus {
|
||||
x: number
|
||||
y: number
|
||||
zoom: number
|
||||
}
|
||||
|
||||
enum ScaleType {
|
||||
Linear = 'linear',
|
||||
Logarithmic = 'logarithmic',
|
||||
}
|
||||
|
||||
interface AxisConfiguration {
|
||||
label?: string
|
||||
scale: ScaleType
|
||||
}
|
||||
|
||||
interface AxesConfiguration {
|
||||
x: AxisConfiguration
|
||||
y: AxisConfiguration
|
||||
}
|
||||
|
||||
interface Bin {
|
||||
x0: number | undefined
|
||||
x1: number | undefined
|
||||
length: number | undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line no-redeclare
|
||||
import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7.8.5/+esm'
|
||||
|
||||
import type { BrushSelection, D3BrushEvent, D3ZoomEvent, ScaleSequential, ZoomTransform } from 'd3'
|
||||
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
import { useVisualizationConfig } from '@/providers/useVisualizationConfig.ts'
|
||||
|
||||
import { useEvent, useEventConditional } from './events.ts'
|
||||
import { getTextWidth } from './measurement.ts'
|
||||
|
||||
const shortcuts = {
|
||||
zoomIn: (e: KeyboardEvent) => (e.ctrlKey || e.metaKey) && e.key === 'z',
|
||||
showAll: (e: KeyboardEvent) => (e.ctrlKey || e.metaKey) && e.key === 'a',
|
||||
}
|
||||
|
||||
const MARGIN = 25
|
||||
const AXIS_LABEL_HEIGHT = 10
|
||||
const ANIMATION_DURATION_MS = 400
|
||||
const DEFAULT_NUMBER_OF_BINS = 50
|
||||
const COLOR_LEGEND_WIDTH = 5
|
||||
const DEFAULT_AXES_CONFIGURATION: AxesConfiguration = {
|
||||
x: { scale: ScaleType.Linear },
|
||||
y: { scale: ScaleType.Linear },
|
||||
}
|
||||
const RMB_DIVIDER = 100
|
||||
const PINCH_DIVIDER = 100
|
||||
|
||||
const EPSILON = 0.001
|
||||
const ZOOM_EXTENT = [0.5, 20] satisfies BrushSelection
|
||||
const RIGHT_BUTTON = 2
|
||||
const MID_BUTTON = 1
|
||||
const MID_BUTTON_CLICKED = 4
|
||||
const SCROLL_WHEEL = 0
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
const containerNode = ref<HTMLElement>()
|
||||
const xAxisNode = ref<SVGGElement>()
|
||||
const yAxisNode = ref<SVGGElement>()
|
||||
const plotNode = ref<SVGGElement>()
|
||||
const colorLegendGradientNode = ref<SVGElement>()
|
||||
const zoomNode = ref<SVGGElement>()
|
||||
const brushNode = ref<SVGGElement>()
|
||||
|
||||
const d3XAxis = computed(() => d3.select(xAxisNode.value))
|
||||
const d3YAxis = computed(() => d3.select(yAxisNode.value))
|
||||
const d3Plot = computed(() => d3.select(plotNode.value))
|
||||
const d3ColorLegendGradient = computed(() => d3.select(colorLegendGradientNode.value))
|
||||
const d3Zoom = computed(() => d3.select(zoomNode.value))
|
||||
const d3Brush = computed(() => d3.select(brushNode.value))
|
||||
|
||||
const points = ref<number[]>([])
|
||||
const rawBins = ref<number[]>()
|
||||
const binCount = ref(DEFAULT_NUMBER_OF_BINS)
|
||||
const axis = ref(DEFAULT_AXES_CONFIGURATION)
|
||||
const rawFocus = ref<Focus>()
|
||||
const brushExtent = ref<BrushSelection>()
|
||||
const zoomLevel = ref(1)
|
||||
const shouldAnimate = ref(false)
|
||||
|
||||
const xDomain = ref([0, 1])
|
||||
const yDomain = ref([0, 1])
|
||||
|
||||
// The maximum value MUST NOT be 0, otherwise 0 will be in the middle of the y axis.
|
||||
const yMax = computed(() => d3.max(bins.value, (d) => d.length) || 1)
|
||||
const originalXScale = computed(() =>
|
||||
d3.scaleLinear().range([0, boxWidth.value]).domain(xExtents.value),
|
||||
)
|
||||
const xScale = computed(() => d3.scaleLinear().domain(xDomain.value).range([0, boxWidth.value]))
|
||||
const yScale = computed(() => d3.scaleLinear().domain(yDomain.value).range([boxHeight.value, 0]))
|
||||
const fill = computed(() =>
|
||||
d3.scaleSequential().domain(yDomain.value).interpolator(d3.interpolateViridis),
|
||||
)
|
||||
const yAxis = computed(() => {
|
||||
const yTicks = yScale.value.ticks().filter(Number.isInteger)
|
||||
return d3.axisLeft(yScale.value).tickFormat(d3.format('d')).tickValues(yTicks)
|
||||
})
|
||||
|
||||
const animationDuration = ref(() => (shouldAnimate.value ? ANIMATION_DURATION_MS : 0))
|
||||
const bins = computed<Bin[]>(() => {
|
||||
if (rawBins.value != null) {
|
||||
return rawBins.value.map((length, i) => ({ x0: i, x1: i + 1, length }))
|
||||
} else if (points.value != null) {
|
||||
const dataDomain = originalXScale.value.domain()
|
||||
const histogram = d3
|
||||
.bin()
|
||||
.domain([dataDomain[0] ?? 0, dataDomain[1] ?? 1])
|
||||
.thresholds(originalXScale.value.ticks(binCount.value))
|
||||
return histogram(points.value)
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
let rawData = props.data
|
||||
if (rawData == null) {
|
||||
console.error('Histogram was not passed any data.')
|
||||
} else {
|
||||
const isUpdate = rawData.update === 'diff'
|
||||
let newData: HistogramInnerData | number[] = []
|
||||
if (Array.isArray(rawData)) {
|
||||
newData = rawData
|
||||
rawData = {} as Data
|
||||
}
|
||||
if (isUpdate) {
|
||||
if (rawData.data != null) {
|
||||
newData = rawData.data
|
||||
}
|
||||
} else if (rawData.data != null) {
|
||||
newData = rawData.data
|
||||
} else if (Array.isArray(rawData)) {
|
||||
newData = rawData
|
||||
} else if (rawData.json != null && Array.isArray(rawData.json)) {
|
||||
newData = rawData.json
|
||||
} else {
|
||||
newData = []
|
||||
}
|
||||
|
||||
if (rawData.axis != null) {
|
||||
axis.value = rawData.axis
|
||||
}
|
||||
if (rawData.focus != null) {
|
||||
rawFocus.value = rawData.focus
|
||||
}
|
||||
if (rawData.bins != null) {
|
||||
binCount.value = Math.max(1, rawData.bins)
|
||||
}
|
||||
if (!isUpdate) {
|
||||
rawBins.value = rawData.data?.bins ?? undefined
|
||||
}
|
||||
|
||||
const values = Array.isArray(newData) ? newData : newData?.values ?? []
|
||||
points.value = values.filter((value) => typeof value === 'number' && !Number.isNaN(value))
|
||||
}
|
||||
})
|
||||
|
||||
// =================
|
||||
// === Positions ===
|
||||
// =================
|
||||
|
||||
const margin = computed(() => ({
|
||||
top: MARGIN / 2.0,
|
||||
right: MARGIN / 2.0,
|
||||
bottom: MARGIN + (axis.value?.x?.label ? AXIS_LABEL_HEIGHT : 0),
|
||||
left: MARGIN + (axis.value?.y?.label ? AXIS_LABEL_HEIGHT : 0),
|
||||
}))
|
||||
const width = ref(Math.max(config.value.width ?? 0, config.value.nodeSize.x))
|
||||
watchPostEffect(() => {
|
||||
width.value = config.value.fullscreen
|
||||
? containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
: Math.max(config.value.width ?? 0, config.value.nodeSize.x)
|
||||
})
|
||||
const height = ref(config.value.height ?? (config.value.nodeSize.x * 3) / 4)
|
||||
watchPostEffect(() => {
|
||||
height.value = config.value.fullscreen
|
||||
? containerNode.value?.parentElement?.clientHeight ?? 0
|
||||
: config.value.height ?? (config.value.nodeSize.x * 3) / 4
|
||||
})
|
||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
||||
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
||||
const xLabelTop = computed(() => boxHeight.value + margin.value.bottom - AXIS_LABEL_HEIGHT / 2)
|
||||
const xLabelLeft = computed(() => boxWidth.value / 2 + getTextWidth(axis.value.x.label) / 2)
|
||||
const yLabelTop = computed(() => -margin.value.left + AXIS_LABEL_HEIGHT)
|
||||
const yLabelLeft = computed(() => -boxHeight.value / 2 + getTextWidth(axis.value.y.label) / 2)
|
||||
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startClientX = 0
|
||||
let startClientY = 0
|
||||
let actionStartXScale = xScale.value.copy()
|
||||
let actionStartZoomLevel = zoomLevel.value
|
||||
|
||||
function getScaleForZoom(scale: number) {
|
||||
return d3.zoomIdentity
|
||||
.translate(startX - (AXIS_LABEL_HEIGHT + MARGIN), startY - MARGIN)
|
||||
.scale(scale)
|
||||
.translate(-startX + (AXIS_LABEL_HEIGHT + MARGIN), -startY + MARGIN)
|
||||
}
|
||||
|
||||
/** Initialise panning and zooming functionality on the visualization. */
|
||||
const zoom = computed(() =>
|
||||
d3
|
||||
.zoom<SVGGElement, unknown>()
|
||||
.filter((event: Event) => {
|
||||
if (
|
||||
event instanceof MouseEvent &&
|
||||
event.type === 'mousedown' &&
|
||||
(event.button === RIGHT_BUTTON || event.button === MID_BUTTON)
|
||||
) {
|
||||
return true
|
||||
} else if (
|
||||
event instanceof WheelEvent &&
|
||||
event.type === 'wheel' &&
|
||||
event.button === SCROLL_WHEEL
|
||||
) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.wheelDelta((event) => {
|
||||
const minDelta = 0.002
|
||||
const medDelta = 0.05
|
||||
const maxDelta = 1
|
||||
const wheelSpeedMultiplier =
|
||||
event.deltaMode === 1 ? medDelta : event.deltaMode ? maxDelta : minDelta
|
||||
return -event.deltaY * wheelSpeedMultiplier
|
||||
})
|
||||
.scaleExtent(ZOOM_EXTENT)
|
||||
.extent([
|
||||
[0, 0],
|
||||
[boxWidth.value, boxHeight.value],
|
||||
])
|
||||
.on('zoom', zoomed)
|
||||
.on('start', startZoom),
|
||||
)
|
||||
watchEffect(() => d3Zoom.value.call(zoom.value))
|
||||
|
||||
/** Helper function called on pan/scroll. */
|
||||
function zoomed(event: D3ZoomEvent<Element, unknown>) {
|
||||
shouldAnimate.value = false
|
||||
const xScale_ = xScale.value
|
||||
const yScale_ = yScale.value
|
||||
|
||||
function innerRescale(transformEvent: ZoomTransform) {
|
||||
xDomain.value = transformEvent.rescaleX(xScale_).domain()
|
||||
const newYDomain = transformEvent.rescaleY(yScale_).domain()
|
||||
const yMin = newYDomain[0] ?? 0
|
||||
if (yMin >= -EPSILON && yMin <= EPSILON) {
|
||||
newYDomain[0] -= yMin
|
||||
newYDomain[1] -= yMin
|
||||
}
|
||||
if ((newYDomain[0] ?? 0) >= 0) {
|
||||
yDomain.value = newYDomain
|
||||
}
|
||||
}
|
||||
|
||||
if (event.sourceEvent instanceof MouseEvent && event.sourceEvent.buttons === RIGHT_BUTTON) {
|
||||
const zoomAmount = rmbZoomValue(event.sourceEvent) / RMB_DIVIDER
|
||||
const scale = Math.exp(zoomAmount)
|
||||
const distanceScale = getScaleForZoom(scale)
|
||||
xScale_.domain(actionStartXScale.domain())
|
||||
xDomain.value = distanceScale.rescaleX(xScale_).domain()
|
||||
zoomLevel.value = actionStartZoomLevel * scale
|
||||
} else if (event.sourceEvent instanceof WheelEvent) {
|
||||
if (event.sourceEvent.ctrlKey) {
|
||||
const zoomAmount = -event.sourceEvent.deltaY / PINCH_DIVIDER
|
||||
const scale = Math.exp(zoomAmount)
|
||||
const distanceScale = getScaleForZoom(scale)
|
||||
xDomain.value = distanceScale.rescaleX(xScale_).domain()
|
||||
zoomLevel.value *= scale
|
||||
} else {
|
||||
const distanceScale = d3.zoomIdentity.translate(
|
||||
-event.sourceEvent.deltaX,
|
||||
-event.sourceEvent.deltaY,
|
||||
)
|
||||
innerRescale(distanceScale)
|
||||
}
|
||||
} else if (
|
||||
event.sourceEvent instanceof MouseEvent &&
|
||||
event.sourceEvent.buttons === MID_BUTTON_CLICKED
|
||||
) {
|
||||
const movementFactor = 2
|
||||
const distanceScale = d3.zoomIdentity.translate(
|
||||
event.sourceEvent.movementX / movementFactor,
|
||||
event.sourceEvent.movementY / movementFactor,
|
||||
)
|
||||
innerRescale(distanceScale)
|
||||
} else {
|
||||
innerRescale(event.transform)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the zoom value computed from the initial right-mouse-button event to the current
|
||||
* right-mouse event. */
|
||||
function rmbZoomValue(event: MouseEvent | WheelEvent | undefined) {
|
||||
const dX = (event?.clientX ?? 0) - startClientX
|
||||
const dY = (event?.clientY ?? 0) - startClientY
|
||||
return dX - dY
|
||||
}
|
||||
|
||||
/** Helper function called when starting to pan/scroll. */
|
||||
function startZoom(event: D3ZoomEvent<Element, unknown>) {
|
||||
startX = event.sourceEvent?.offsetX ?? 0
|
||||
startY = event.sourceEvent?.offsetY ?? 0
|
||||
startClientX = event.sourceEvent?.clientX ?? 0
|
||||
startClientY = event.sourceEvent?.clientY ?? 0
|
||||
actionStartXScale = xScale.value.copy()
|
||||
actionStartZoomLevel = zoomLevel.value
|
||||
}
|
||||
|
||||
const brush = computed(() =>
|
||||
d3
|
||||
.brushX()
|
||||
.extent([
|
||||
[0, 0],
|
||||
[boxWidth.value, boxHeight.value],
|
||||
])
|
||||
.on('start brush', (event: D3BrushEvent<unknown>) => {
|
||||
brushExtent.value = event.selection ?? undefined
|
||||
}),
|
||||
)
|
||||
// Note: The brush element must be a child of the zoom element - this is only way we found to have
|
||||
// both zoom and brush events working at the same time. See https://stackoverflow.com/a/59757276.
|
||||
watchEffect(() => d3Brush.value.call(brush.value))
|
||||
|
||||
/** Zoom into the selected area of the plot.
|
||||
*
|
||||
* Based on https://www.d3-graph-gallery.com/graph/interactivity_brush.html
|
||||
* Section "Brushing for zooming". */
|
||||
function zoomToSelected() {
|
||||
if (brushExtent.value == null) {
|
||||
return
|
||||
}
|
||||
rawFocus.value = undefined
|
||||
const xScale_ = xScale.value
|
||||
const startRaw = brushExtent.value[0]
|
||||
const endRaw = brushExtent.value[1]
|
||||
const start = typeof startRaw === 'number' ? startRaw : startRaw[0]
|
||||
const end = typeof endRaw === 'number' ? endRaw : endRaw[0]
|
||||
const selectionWidth = end - start
|
||||
zoomLevel.value *= boxWidth.value / selectionWidth
|
||||
const xMin = xScale_.invert(start)
|
||||
const xMax = xScale_.invert(end)
|
||||
xDomain.value = [xMin, xMax]
|
||||
shouldAnimate.value = true
|
||||
}
|
||||
|
||||
function endBrushing() {
|
||||
brushExtent.value = undefined
|
||||
d3Brush.value.call(brush.value.move, null)
|
||||
}
|
||||
|
||||
useEventConditional(
|
||||
document,
|
||||
'keydown',
|
||||
() => brushExtent.value != null,
|
||||
(event) => {
|
||||
if (shortcuts.zoomIn(event)) {
|
||||
zoomToSelected()
|
||||
endBrushing()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Return the extrema of the data and and paddings that ensure data will fit into the
|
||||
* drawing area.
|
||||
*
|
||||
* It traverses through data getting minimal and maximal values, and calculates padding based on
|
||||
* span calculated from above values, multiplied by 10% so that the plot is a little bit smaller
|
||||
* than the container.
|
||||
*/
|
||||
const extremesAndDeltas = computed(() => {
|
||||
let xMin = rawBins.value != null ? 0 : Math.min(...points.value)
|
||||
let xMax = rawBins.value != null ? rawBins.value.length - 1 : Math.max(...points.value)
|
||||
const dx = xMax - xMin
|
||||
const binCount_ = rawBins.value?.length || binCount.value
|
||||
const paddingX = Math.max(0.1 * dx, 1 / binCount_)
|
||||
return { xMin, xMax, paddingX, dx }
|
||||
})
|
||||
|
||||
const xExtents = computed<[min: number, max: number]>(() => {
|
||||
const extremesAndDeltas_ = extremesAndDeltas.value
|
||||
return [
|
||||
extremesAndDeltas_.xMin - extremesAndDeltas_.paddingX,
|
||||
extremesAndDeltas_.xMax + extremesAndDeltas_.paddingX,
|
||||
]
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const focus_ = rawFocus.value
|
||||
if (focus_?.x != null && focus_.zoom != null) {
|
||||
let paddingX = extremesAndDeltas.value.dx / (2 * focus_.zoom)
|
||||
xDomain.value = [focus_.x - paddingX, focus_.x + paddingX]
|
||||
} else {
|
||||
xDomain.value = xExtents.value
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Update height of the color legend to match the height of the canvas.
|
||||
* Set up `stop` attributes on color legend gradient to match `colorScale`, so color legend shows correct colors
|
||||
* used by histogram.
|
||||
*/
|
||||
function updateColorLegend(colorScale: ScaleSequential<string>) {
|
||||
const colorScaleToGradient = (t: number, i: number, n: number[]) => ({
|
||||
offset: `${(100 * i) / n.length}%`,
|
||||
color: colorScale(t),
|
||||
})
|
||||
d3ColorLegendGradient.value
|
||||
.selectAll('stop')
|
||||
.data(colorScale.ticks().map(colorScaleToGradient))
|
||||
.enter()
|
||||
.append('stop')
|
||||
.attr('offset', (d) => d.offset)
|
||||
.attr('stop-color', (d) => d.color)
|
||||
}
|
||||
|
||||
// =============
|
||||
// === Setup ===
|
||||
// =============
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:preprocessor', 'Standard.Visualization.Histogram', 'process_to_json_text')
|
||||
})
|
||||
|
||||
// ==============
|
||||
// === Update ===
|
||||
// ==============
|
||||
|
||||
watch([boxWidth, boxHeight], () => {
|
||||
shouldAnimate.value = false
|
||||
queueMicrotask(endBrushing)
|
||||
})
|
||||
|
||||
// === Update x axis ===
|
||||
|
||||
watchPostEffect(() =>
|
||||
d3XAxis.value
|
||||
.transition()
|
||||
.duration(animationDuration.value)
|
||||
.call(d3.axisBottom(xScale.value).ticks(width.value / 40)),
|
||||
)
|
||||
|
||||
// === Update y axis ===
|
||||
|
||||
watchEffect(() => {
|
||||
if (yDomain.value[0] === 0 && yDomain.value[1] !== yMax.value) {
|
||||
shouldAnimate.value = true
|
||||
yDomain.value = [0, yMax.value]
|
||||
}
|
||||
})
|
||||
|
||||
watchPostEffect(() =>
|
||||
d3YAxis.value.transition().duration(animationDuration.value).call(yAxis.value),
|
||||
)
|
||||
|
||||
// === Update contents ===
|
||||
|
||||
watchPostEffect(() => {
|
||||
const originalXScale_ = originalXScale.value
|
||||
const xScale_ = xScale.value
|
||||
const yScale_ = yScale.value
|
||||
const boxHeight_ = boxHeight.value
|
||||
const fill_ = fill.value
|
||||
const zoomLevel_ = zoomLevel.value
|
||||
updateColorLegend(fill_)
|
||||
|
||||
d3Plot.value
|
||||
.selectAll('rect')
|
||||
.data(bins.value)
|
||||
.join((enter) => enter.append('rect').attr('x', 1))
|
||||
.transition()
|
||||
.duration(animationDuration.value)
|
||||
.attr(
|
||||
'transform',
|
||||
(d) => `translate(${xScale_(d.x0 ?? 0)}, ${yScale_(d.length ?? 0)}) scale(${zoomLevel_}, 1)`,
|
||||
)
|
||||
.attr('width', (d) => originalXScale_(d.x1 ?? 0) - originalXScale_(d.x0 ?? 0))
|
||||
.attr('height', (d) => Math.max(0, boxHeight_ - yScale_(d.length ?? 0)))
|
||||
.style('fill', (d) => fill_(d.length ?? 0))
|
||||
})
|
||||
|
||||
// ======================
|
||||
// === Event handlers ===
|
||||
// ======================
|
||||
|
||||
function fitAll() {
|
||||
rawFocus.value = undefined
|
||||
zoomLevel.value = 1
|
||||
xDomain.value = originalXScale.value.domain()
|
||||
shouldAnimate.value = true
|
||||
}
|
||||
|
||||
useEvent(document, 'keydown', (event) => {
|
||||
if (shortcuts.showAll(event)) {
|
||||
fitAll()
|
||||
}
|
||||
})
|
||||
useEvent(document, 'click', endBrushing)
|
||||
useEvent(document, 'auxclick', endBrushing)
|
||||
useEvent(document, 'contextmenu', endBrushing)
|
||||
useEvent(document, 'scroll', endBrushing)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<template #toolbar>
|
||||
<button class="image-button active">
|
||||
<SvgIcon name="show_all" alt="Fit all" @click="fitAll" />
|
||||
</button>
|
||||
<button class="image-button" :class="{ active: brushExtent != null }">
|
||||
<SvgIcon name="find" alt="Zoom to selected" @click="zoomToSelected" />
|
||||
</button>
|
||||
</template>
|
||||
<div ref="containerNode" class="HistogramVisualization" @pointerdown.stop>
|
||||
<svg :width="width" :height="height">
|
||||
<rect
|
||||
class="color-legend"
|
||||
:width="COLOR_LEGEND_WIDTH"
|
||||
:height="boxHeight"
|
||||
:transform="`translate(${margin.left - COLOR_LEGEND_WIDTH}, ${margin.top})`"
|
||||
:style="{ fill: 'url(#color-legend-gradient)' }"
|
||||
/>
|
||||
<g :transform="`translate(${margin.left}, ${margin.top})`">
|
||||
<defs>
|
||||
<clipPath id="histogram-clip-path">
|
||||
<rect :width="boxWidth" :height="boxHeight"></rect>
|
||||
</clipPath>
|
||||
<linearGradient
|
||||
id="color-legend-gradient"
|
||||
ref="colorLegendGradientNode"
|
||||
x1="0%"
|
||||
y1="100%"
|
||||
x2="0%"
|
||||
y2="0%"
|
||||
></linearGradient>
|
||||
</defs>
|
||||
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
|
||||
<g ref="yAxisNode" class="axis-y"></g>
|
||||
<text
|
||||
v-if="axis.x.label"
|
||||
class="label label-x"
|
||||
text-anchor="end"
|
||||
:x="xLabelLeft"
|
||||
:y="xLabelTop"
|
||||
v-text="axis.x.label"
|
||||
></text>
|
||||
<text
|
||||
v-if="axis.y.label"
|
||||
class="label label-y"
|
||||
text-anchor="end"
|
||||
:x="yLabelLeft"
|
||||
:y="yLabelTop"
|
||||
v-text="axis.y.label"
|
||||
></text>
|
||||
<g ref="plotNode" clip-path="url(#histogram-clip-path)"></g>
|
||||
<g ref="zoomNode" class="zoom">
|
||||
<g ref="brushNode" class="brush"></g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.HistogramVisualization {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.HistogramVisualization .selection {
|
||||
rx: 4px;
|
||||
stroke: transparent;
|
||||
}
|
||||
|
||||
.label-y {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
</style>
|
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
export const name = 'Image'
|
||||
export const inputType = 'Standard.Image.Data.Image.Image'
|
||||
|
||||
interface Data {
|
||||
mediaType?: string
|
||||
base64: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
|
||||
const DEFAULT_MEDIA_TYPE = 'image/png'
|
||||
|
||||
const src = computed(
|
||||
() => `data:${props.data.mediaType ?? DEFAULT_MEDIA_TYPE};base64,${props.data.base64}`,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-node="true">
|
||||
<div class="ImageVisualization">
|
||||
<img :src="src" />
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ImageVisualization {
|
||||
> img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
37
app/gui2/src/components/visualizations/JSONVisualization.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
export const name = 'JSON'
|
||||
export const inputType = 'Any'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
|
||||
type Data = Record<string, unknown>
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string]
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:preprocessor', 'Standard.Visualization.Preprocessor', 'error_preprocessor')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<div class="JSONVisualization" v-text="data"></div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://fonts.cdnfonts.com/css/dejavu-sans-mono');
|
||||
|
||||
.JSONVisualization {
|
||||
font-family: 'DejaVu Sans Mono', monospace;
|
||||
white-space: pre;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
203
app/gui2/src/components/visualizations/SQLVisualization.vue
Normal file
@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
export const name = 'SQL Query'
|
||||
export const inputType = 'Standard.Database.Data.Table.Table | Standard.Database.Data.Column.Column'
|
||||
|
||||
/**
|
||||
* A visualization that pretty-prints generated SQL code and displays type hints related to
|
||||
* interpolated query parameters.
|
||||
*/
|
||||
|
||||
type Data = SQLData | Error
|
||||
|
||||
interface SQLData {
|
||||
error: undefined
|
||||
dialect: string
|
||||
code: string
|
||||
interpolations: SQLInterpolation[]
|
||||
}
|
||||
|
||||
interface SQLInterpolation {
|
||||
enso_type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Error {
|
||||
error: string
|
||||
dialect: undefined
|
||||
code: undefined
|
||||
interpolations: undefined
|
||||
}
|
||||
|
||||
declare const sqlFormatter: typeof import('sql-formatter')
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line no-redeclare
|
||||
import * as sqlFormatter from 'https://cdn.jsdelivr.net/npm/sql-formatter@13.0.0/+esm'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
import { DEFAULT_THEME, type RGBA, type Theme } from './builtins.ts'
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
const theme: Theme = DEFAULT_THEME
|
||||
|
||||
const language = computed(() =>
|
||||
props.data.dialect != null && sqlFormatter.supportedDialects.includes(props.data.dialect)
|
||||
? props.data.dialect
|
||||
: 'sql',
|
||||
)
|
||||
const formatted = computed(() => {
|
||||
if (props.data.error != null) {
|
||||
return undefined
|
||||
}
|
||||
const params = props.data.interpolations.map((param) =>
|
||||
renderInterpolationParameter(theme, param),
|
||||
)
|
||||
|
||||
return sqlFormatter.format(props.data.code, {
|
||||
params: params,
|
||||
language: language.value,
|
||||
})
|
||||
})
|
||||
|
||||
/** The qualified name of the Text type. */
|
||||
const TEXT_TYPE = 'Builtins.Main.Text'
|
||||
/** Specifies opacity of interpolation background color. */
|
||||
const INTERPOLATION_BACKGROUND_OPACITY = 0.2
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:preprocessor', 'Standard.Visualization.SQL.Visualization', 'prepare_visualization')
|
||||
})
|
||||
|
||||
// === Handling Colors ===
|
||||
|
||||
/** Render a 4-element array representing a color into a CSS-compatible rgba string. */
|
||||
function convertColorToRgba(color: RGBA) {
|
||||
const r = 255 * color.red
|
||||
const g = 255 * color.green
|
||||
const b = 255 * color.blue
|
||||
const a = color.alpha
|
||||
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'
|
||||
}
|
||||
|
||||
/** Replace the alpha component of a color (represented as a 4-element array),
|
||||
* returning a new color. */
|
||||
function replaceAlpha(color: RGBA, newAlpha: number) {
|
||||
return {
|
||||
red: color.red,
|
||||
green: color.green,
|
||||
blue: color.blue,
|
||||
alpha: newAlpha,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders HTML for displaying an Enso parameter that is interpolated into the SQL code.
|
||||
*/
|
||||
function renderInterpolationParameter(theme: Theme, param: { enso_type: string; value: string }) {
|
||||
const actualType = param.enso_type
|
||||
let value = param.value
|
||||
|
||||
if (actualType === TEXT_TYPE) {
|
||||
value = "'" + value.replace(/'/g, "''") + "'"
|
||||
}
|
||||
|
||||
const actualTypeColor = theme.getColorForType(actualType)
|
||||
const fgColor = actualTypeColor
|
||||
let bgColor = replaceAlpha(fgColor, INTERPOLATION_BACKGROUND_OPACITY)
|
||||
|
||||
return renderRegularInterpolation(value, fgColor, bgColor)
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper that renders the HTML representation of a regular SQL interpolation.
|
||||
*/
|
||||
function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA) {
|
||||
let html = `<div class="interpolation" style="color:${convertColorToRgba(
|
||||
fgColor,
|
||||
)};background-color:${convertColorToRgba(bgColor)};">`
|
||||
html += value
|
||||
html += '</div>'
|
||||
return html
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<div class="sql-visualization scrollable">
|
||||
<pre v-if="data.error" class="sql" v-text="data.error"></pre>
|
||||
<!-- eslint-disable-next-line vue/no-v-html This is SAFE, beause it is not user input. -->
|
||||
<pre v-else class="sql" v-html="formatted"></pre>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://fonts.cdnfonts.com/css/dejavu-sans-mono');
|
||||
|
||||
.sql-visualization {
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.sql-visualization .sql {
|
||||
font-family: 'DejaVu Sans Mono', monospace;
|
||||
font-size: 12px;
|
||||
margin-left: 7px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.sql-visualization .interpolation {
|
||||
border-radius: 6px;
|
||||
padding: 1px 2px 1px 2px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sql-visualization .mismatch-parent {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sql-visualization .mismatch-mouse-area {
|
||||
display: inline;
|
||||
position: absolute;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
align-self: center;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sql-visualization .mismatch {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sql-visualization .modulepath {
|
||||
color: rgba(150, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.sql-visualization .tooltip {
|
||||
font-family: DejaVuSansMonoBook, sans-serif;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
background-color: rgba(249, 249, 249, 1);
|
||||
box-shadow: 0 0 16px rgba(0, 0, 0, 0.16);
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
z-index: 99999;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
479
app/gui2/src/components/visualizations/TableVisualization.vue
Normal file
@ -0,0 +1,479 @@
|
||||
<script lang="ts">
|
||||
export const name = 'Table'
|
||||
export const inputType =
|
||||
'Standard.Table.Data.Table.Table | Standard.Table.Data.Column.Column | Standard.Table.Data.Row.Row |Standard.Base.Data.Vector.Vector | Standard.Base.Data.Array.Array | Standard.Base.Data.Map.Map | Any'
|
||||
|
||||
type Data = Error | Matrix | ObjectMatrix | LegacyMatrix | LegacyObjectMatrix | UnknownTable
|
||||
|
||||
interface Error {
|
||||
type: undefined
|
||||
error: string
|
||||
all_rows_count?: undefined
|
||||
}
|
||||
|
||||
interface Matrix {
|
||||
type: 'Matrix'
|
||||
column_count: number
|
||||
all_rows_count: number
|
||||
json: unknown[][]
|
||||
}
|
||||
|
||||
interface ObjectMatrix {
|
||||
type: 'Object_Matrix'
|
||||
column_count: number
|
||||
all_rows_count: number
|
||||
json: object[]
|
||||
}
|
||||
|
||||
interface LegacyMatrix {
|
||||
type: undefined
|
||||
column_count: number
|
||||
all_rows_count: number
|
||||
json: unknown[][]
|
||||
}
|
||||
|
||||
interface LegacyObjectMatrix {
|
||||
type: undefined
|
||||
column_count: number
|
||||
all_rows_count: number
|
||||
json: object[]
|
||||
}
|
||||
|
||||
interface UnknownTable {
|
||||
// This is INCORRECT. It is actually a string, however we do not need to access this.
|
||||
// Setting it to `string` breaks the discriminated union detection that is being used to
|
||||
// distinguish `Matrix` and `ObjectMatrix`.
|
||||
type: undefined
|
||||
json: unknown
|
||||
all_rows_count?: number
|
||||
header: string[]
|
||||
indices_header?: string[]
|
||||
data: unknown[][] | undefined
|
||||
indices: unknown[][] | undefined
|
||||
}
|
||||
|
||||
declare const agGrid: typeof import('ag-grid-enterprise')
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch, watchEffect, type Ref } from 'vue'
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line no-redeclare
|
||||
import * as agGrid from 'https://cdn.jsdelivr.net/npm/ag-grid-enterprise@30.1.0/+esm'
|
||||
|
||||
import type {
|
||||
ColDef,
|
||||
ColumnResizedEvent,
|
||||
GridOptions,
|
||||
HeaderValueGetterParams,
|
||||
} from 'ag-grid-community'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
import { useVisualizationConfig } from '@/providers/useVisualizationConfig.ts'
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
const INDEX_FIELD_NAME = '#'
|
||||
const SIDE_MARGIN = 20
|
||||
|
||||
const rowLimit = ref(0)
|
||||
const page = ref(0)
|
||||
const pageLimit = ref(0)
|
||||
const rowCount = ref(0)
|
||||
const isTruncated = ref(false)
|
||||
const tableNode = ref<HTMLElement>()
|
||||
const defaultColDef = {
|
||||
editable: false,
|
||||
sortable: true as boolean,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
minWidth: 25,
|
||||
headerValueGetter: (params: HeaderValueGetterParams) => params.colDef.field,
|
||||
cellRenderer: cellRenderer,
|
||||
}
|
||||
const agGridOptions: Ref<GridOptions & Required<Pick<GridOptions, 'defaultColDef'>>> = ref({
|
||||
headerHeight: 20,
|
||||
rowHeight: 20,
|
||||
rowData: [],
|
||||
columnDefs: [],
|
||||
defaultColDef: defaultColDef as typeof defaultColDef & { manuallySized: boolean },
|
||||
onColumnResized: lockColumnSize,
|
||||
suppressFieldDotNotation: true,
|
||||
})
|
||||
|
||||
const isFirstPage = computed(() => page.value === 0)
|
||||
const isLastPage = computed(() => page.value === pageLimit.value - 1)
|
||||
const isRowCountSelectorVisible = computed(() => rowCount.value >= 1000)
|
||||
const selectableRowLimits = computed(() =>
|
||||
[1000, 2500, 5000, 10000, 25000, 50000, 100000].filter((r) => r <= rowCount.value),
|
||||
)
|
||||
const wasAutomaticallyAutosized = ref(false)
|
||||
|
||||
function setRowLimitAndPage(newRowLimit: number, newPage: number) {
|
||||
if (newRowLimit !== rowLimit.value || newPage !== page.value) {
|
||||
rowLimit.value = newRowLimit
|
||||
page.value = newPage
|
||||
emit(
|
||||
'update:preprocessor',
|
||||
'Standard.Visualization.Table.Visualization',
|
||||
'prepare_visualization',
|
||||
newRowLimit.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(str: string) {
|
||||
const mapping: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'>': '>',
|
||||
}
|
||||
return str.replace(/[&<>"']/g, (m) => mapping[m]!)
|
||||
}
|
||||
|
||||
function cellRenderer(params: { value: string | null }) {
|
||||
if (params.value === null) {
|
||||
return '<span style="color:grey; font-style: italic;">Nothing</span>'
|
||||
} else if (params.value === undefined) {
|
||||
return ''
|
||||
} else if (params.value === '') {
|
||||
return '<span style="color:grey; font-style: italic;">Empty</span>'
|
||||
}
|
||||
return escapeHTML(params.value.toString())
|
||||
}
|
||||
|
||||
function addRowIndex(data: object[]): object[] {
|
||||
return data.map((row, i) => ({ [INDEX_FIELD_NAME]: i, ...row }))
|
||||
}
|
||||
|
||||
function hasExactlyKeys(keys: string[], obj: object) {
|
||||
return (
|
||||
Object.keys(obj).length === keys.length &&
|
||||
keys.every((k) => Object.prototype.hasOwnProperty.call(obj, k))
|
||||
)
|
||||
}
|
||||
|
||||
function isObjectMatrix(data: object): data is LegacyObjectMatrix {
|
||||
if (!('json' in data)) {
|
||||
return false
|
||||
}
|
||||
const json = data.json
|
||||
const isList = Array.isArray(json) && json[0] != null
|
||||
if (!isList || !(typeof json[0] === 'object')) {
|
||||
return false
|
||||
}
|
||||
const firstKeys = Object.keys(json[0])
|
||||
return json.every((obj) => hasExactlyKeys(firstKeys, obj))
|
||||
}
|
||||
|
||||
function isMatrix(data: object): data is LegacyMatrix {
|
||||
if (!('json' in data)) {
|
||||
return false
|
||||
}
|
||||
const json = data.json
|
||||
const isList = Array.isArray(json) && json[0] != null
|
||||
if (!isList) {
|
||||
return false
|
||||
}
|
||||
const firstIsArray = Array.isArray(json[0])
|
||||
if (!firstIsArray) {
|
||||
return false
|
||||
}
|
||||
const firstLen = json[0].length
|
||||
return json.every((d) => d.length === firstLen)
|
||||
}
|
||||
|
||||
function toField(name: string): ColDef {
|
||||
return { field: name, manuallySized: false }
|
||||
}
|
||||
|
||||
function indexField(): ColDef {
|
||||
return toField(INDEX_FIELD_NAME)
|
||||
}
|
||||
|
||||
/** Return a human-readable representation of an object. */
|
||||
function toRender(content: unknown) {
|
||||
if (Array.isArray(content)) {
|
||||
if (isMatrix({ json: content })) {
|
||||
return `[Vector ${content.length} rows x ${content[0].length} cols]`
|
||||
} else if (isObjectMatrix({ json: content })) {
|
||||
return `[Table ${content.length} rows x ${Object.keys(content[0]).length} cols]`
|
||||
} else {
|
||||
return `[Vector ${content.length} items]`
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof content === 'object' && content != null) {
|
||||
const type = 'type' in content ? content.type : undefined
|
||||
if ('_display_text_' in content && content['_display_text_']) {
|
||||
return String(content['_display_text_'])
|
||||
} else {
|
||||
return `{ ${type} Object }`
|
||||
}
|
||||
}
|
||||
|
||||
return String(content)
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const data_ = props.data
|
||||
const options = agGridOptions.value
|
||||
if (options.api == null) {
|
||||
return
|
||||
}
|
||||
|
||||
let columnDefs: ColDef[] = []
|
||||
let rowData: object[] = []
|
||||
|
||||
if ('error' in data_) {
|
||||
options.api.setColumnDefs([
|
||||
{
|
||||
field: 'Error',
|
||||
cellStyle: { 'white-space': 'normal' },
|
||||
manuallySized: false,
|
||||
},
|
||||
])
|
||||
options.api.setRowData([{ Error: data_.error }])
|
||||
} else if (data_.type === 'Matrix') {
|
||||
let defs: ColDef[] = [indexField()]
|
||||
for (let i = 0; i < data_.column_count; i++) {
|
||||
defs.push(toField(i.toString()))
|
||||
}
|
||||
columnDefs = defs
|
||||
rowData = addRowIndex(data_.json)
|
||||
isTruncated.value = data_.all_rows_count !== data_.json.length
|
||||
} else if (data_.type === 'Object_Matrix') {
|
||||
let defs: ColDef[] = [indexField()]
|
||||
let keys = new Set<string>()
|
||||
for (const val of data_.json) {
|
||||
if (val != null) {
|
||||
Object.keys(val).forEach((k) => {
|
||||
if (!keys.has(k)) {
|
||||
keys.add(k)
|
||||
defs.push(toField(k))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
columnDefs = defs
|
||||
rowData = addRowIndex(data_.json)
|
||||
isTruncated.value = data_.all_rows_count !== data_.json.length
|
||||
} else if (isMatrix(data_)) {
|
||||
// Kept to allow visualization from older versions of the backend.
|
||||
columnDefs = [indexField(), ...data_.json[0]!.map((_, i) => toField(i.toString()))]
|
||||
rowData = addRowIndex(data_.json)
|
||||
isTruncated.value = data_.all_rows_count !== data_.json.length
|
||||
} else if (isObjectMatrix(data_)) {
|
||||
// Kept to allow visualization from older versions of the backend.
|
||||
columnDefs = [INDEX_FIELD_NAME, ...Object.keys(data_.json[0]!)].map(toField)
|
||||
rowData = addRowIndex(data_.json)
|
||||
isTruncated.value = data_.all_rows_count !== data_.json.length
|
||||
} else if (Array.isArray(data_.json)) {
|
||||
columnDefs = [indexField(), toField('Value')]
|
||||
rowData = data_.json.map((row, i) => ({ [INDEX_FIELD_NAME]: i, Value: toRender(row) }))
|
||||
isTruncated.value = data_.all_rows_count !== data_.json.length
|
||||
} else if (data_.json !== undefined) {
|
||||
columnDefs = [toField('Value')]
|
||||
rowData = [{ Value: toRender(data_.json) }]
|
||||
} else {
|
||||
const indicesHeader = ('indices_header' in data_ ? data_.indices_header : []).map(toField)
|
||||
columnDefs = [...indicesHeader, ...data_.header.map(toField)]
|
||||
|
||||
const rows =
|
||||
data_.data && data_.data.length > 0
|
||||
? data_.data[0]?.length ?? 0
|
||||
: data_.indices && data_.indices.length > 0
|
||||
? data_.indices[0]?.length ?? 0
|
||||
: 0
|
||||
rowData = Array.from({ length: rows }, (_, i) => {
|
||||
const shift = data_.indices ? data_.indices.length : 0
|
||||
return Object.fromEntries(
|
||||
columnDefs.map((h, j) => [
|
||||
h.field,
|
||||
toRender(j < shift ? data_.indices?.[j]?.[i] : data_.data?.[j - shift]?.[i]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
isTruncated.value = data_.all_rows_count !== rowData.length
|
||||
}
|
||||
|
||||
// Update paging
|
||||
const newRowCount = data_.all_rows_count == null ? 1 : data_.all_rows_count
|
||||
rowCount.value = newRowCount
|
||||
const newPageLimit = Math.ceil(newRowCount / rowLimit.value)
|
||||
pageLimit.value = newPageLimit
|
||||
if (page.value > newPageLimit) {
|
||||
page.value = newPageLimit
|
||||
}
|
||||
|
||||
// If data is truncated, we cannot rely on sorting/filtering so will disable.
|
||||
options.defaultColDef.filter = !isTruncated.value
|
||||
options.defaultColDef.sortable = !isTruncated.value
|
||||
options.api.setColumnDefs(columnDefs)
|
||||
options.api.setRowData(rowData)
|
||||
})
|
||||
|
||||
function updateTableSize(clientWidth: number | undefined) {
|
||||
clientWidth ??= tableNode.value?.getBoundingClientRect().width ?? 0
|
||||
const columnApi = agGridOptions.value.columnApi
|
||||
if (columnApi == null) {
|
||||
console.warn('AG Grid column API does not exist.')
|
||||
return
|
||||
}
|
||||
// Resize columns to fit the table width unless the user manually resized them.
|
||||
const cols = columnApi.getAllGridColumns().filter((c) => !c.getColDef().manuallySized)
|
||||
// Compute the maximum width of a column: the client width minus a small margin.
|
||||
const maxWidth = clientWidth - SIDE_MARGIN
|
||||
// Resize based on the data and then shrink any columns that are too wide.
|
||||
wasAutomaticallyAutosized.value = true
|
||||
columnApi.autoSizeColumns(cols, true)
|
||||
const bigCols = cols
|
||||
.filter((c) => c.getActualWidth() > maxWidth)
|
||||
.map((c) => ({ key: c, newWidth: maxWidth, manuallySized: false }))
|
||||
columnApi.setColumnWidths(bigCols)
|
||||
}
|
||||
|
||||
function lockColumnSize(e: ColumnResizedEvent) {
|
||||
// Check if the resize is finished, and it's not from the API (which is triggered by us).
|
||||
if (!e.finished || e.source === 'api') {
|
||||
return
|
||||
}
|
||||
// If the user manually resized (or manually autosized) a column, we don't want to auto-size it
|
||||
// on a resize.
|
||||
const manuallySized = e.source !== 'autosizeColumns' || !wasAutomaticallyAutosized.value
|
||||
wasAutomaticallyAutosized.value = false
|
||||
for (const column of e.columns ?? []) {
|
||||
column.getColDef().manuallySized = manuallySized
|
||||
}
|
||||
}
|
||||
|
||||
function goToFirstPage() {
|
||||
setRowLimitAndPage(rowLimit.value, 0)
|
||||
}
|
||||
|
||||
function goToPreviousPage() {
|
||||
setRowLimitAndPage(rowLimit.value, page.value - 1)
|
||||
}
|
||||
|
||||
function goToNextPage() {
|
||||
setRowLimitAndPage(rowLimit.value, page.value + 1)
|
||||
}
|
||||
|
||||
function goToLastPage() {
|
||||
setRowLimitAndPage(rowLimit.value, pageLimit.value - 1)
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === Updates ===
|
||||
// ===============
|
||||
|
||||
onMounted(() => {
|
||||
setRowLimitAndPage(1000, 0)
|
||||
if ('AG_GRID_LICENSE_KEY' in window && typeof window.AG_GRID_LICENSE_KEY === 'string') {
|
||||
agGrid.LicenseManager.setLicenseKey(window.AG_GRID_LICENSE_KEY)
|
||||
} else {
|
||||
console.warn('The AG_GRID_LICENSE_KEY is not defined.')
|
||||
}
|
||||
new agGrid.Grid(tableNode.value!, agGridOptions.value)
|
||||
setTimeout(() => updateTableSize(undefined), 0)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => config.value.fullscreen,
|
||||
() => queueMicrotask(() => updateTableSize(undefined)),
|
||||
)
|
||||
|
||||
const debouncedUpdateTableSize = useDebounceFn((...args: Parameters<typeof updateTableSize>) => {
|
||||
queueMicrotask(() => {
|
||||
updateTableSize(...args)
|
||||
})
|
||||
}, 500)
|
||||
|
||||
watch(
|
||||
() => [props.data, config.value.width],
|
||||
() => {
|
||||
debouncedUpdateTableSize(undefined)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<div ref="rootNode" class="TableVisualization" @wheel.stop>
|
||||
<div class="table-visualization-status-bar">
|
||||
<button :disabled="isFirstPage" @click="goToFirstPage">«</button>
|
||||
<button :disabled="isFirstPage" @click="goToPreviousPage">‹</button>
|
||||
<select
|
||||
v-if="isRowCountSelectorVisible"
|
||||
@change="setRowLimitAndPage(Number(($event.target as HTMLOptionElement).value), page)"
|
||||
>
|
||||
<option
|
||||
v-for="limit in selectableRowLimits"
|
||||
:key="limit"
|
||||
:value="limit"
|
||||
v-text="limit"
|
||||
></option>
|
||||
</select>
|
||||
<span
|
||||
v-if="isRowCountSelectorVisible && isTruncated"
|
||||
v-text="` of ${rowCount} rows (Sorting/Filtering disabled).`"
|
||||
></span>
|
||||
<span v-else-if="isRowCountSelectorVisible" v-text="' rows.'"></span>
|
||||
<span v-else-if="rowCount === 1" v-text="'1 row.'"></span>
|
||||
<span v-else v-text="`${rowCount} rows.`"></span>
|
||||
<button :disabled="isLastPage" @click="goToNextPage">›</button>
|
||||
<button :disabled="isLastPage" @click="goToLastPage">»</button>
|
||||
</div>
|
||||
<div ref="tableNode" class="scrollable ag-theme-alpine"></div>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://cdn.jsdelivr.net/npm/ag-grid-community/styles/ag-grid.css');
|
||||
@import url('https://cdn.jsdelivr.net/npm/ag-grid-community/styles/ag-theme-alpine.css');
|
||||
|
||||
.TableVisualization {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ag-theme-alpine {
|
||||
--ag-grid-size: 3px;
|
||||
--ag-list-item-height: 20px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.table-visualization-status-bar {
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
padding: 0 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-visualization-status-bar > button {
|
||||
width: 12px;
|
||||
margin: 0 2px;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.TableVisualization > .ag-theme-alpine > .ag-root-wrapper.ag-layout-normal {
|
||||
border-radius: 0 0 var(--radius-default) var(--radius-default);
|
||||
}
|
||||
</style>
|
@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
export const name = 'Warnings'
|
||||
export const inputType = 'Any'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
|
||||
type Data = string[]
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:preprocessor', 'Standard.Visualization.Warnings', 'process_to_json_text')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<div class="WarningsVisualization">
|
||||
<ul>
|
||||
<li v-if="data.length === 0">There are no warnings.</li>
|
||||
<li v-for="(warning, index) in data" :key="index" v-text="warning"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.WarningsVisualization {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
ul {
|
||||
white-space: pre;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
31
app/gui2/src/components/visualizations/builtins.ts
Normal file
@ -0,0 +1,31 @@
|
||||
export interface Vec2 {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
export interface RGBA {
|
||||
red: number
|
||||
green: number
|
||||
blue: number
|
||||
alpha: number
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
getColorForType(type: string): RGBA
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME: Theme = {
|
||||
getColorForType(type) {
|
||||
let hash = 0
|
||||
for (const c of type) {
|
||||
hash = 0 | (hash * 31 + c.charCodeAt(0))
|
||||
}
|
||||
if (hash < 0) {
|
||||
hash += 0x80000000
|
||||
}
|
||||
const red = (hash >> 24) / 0x180
|
||||
const green = ((hash >> 16) & 0xff) / 0x180
|
||||
const blue = ((hash >> 8) & 0xff) / 0x180
|
||||
return { red, green, blue, alpha: 1 }
|
||||
},
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
// Fixes and extensions for dependencies' type definitions.
|
||||
|
||||
import type * as d3Types from 'd3'
|
||||
|
||||
declare module 'd3' {
|
||||
function select<GElement extends d3Types.BaseType, OldDatum>(
|
||||
node: GElement | null | undefined,
|
||||
): d3Types.Selection<GElement, OldDatum, null, undefined>
|
||||
|
||||
// These type parameters are defined on the original interface.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ScaleSequential<Output, Unknown = never> {
|
||||
ticks(): number[]
|
||||
}
|
||||
}
|
||||
|
||||
import {} from 'ag-grid-community'
|
||||
|
||||
declare module 'ag-grid-community' {
|
||||
// These type parameters are defined on the original interface.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColDef<TData, TValue> {
|
||||
/** Custom user-defined value. */
|
||||
manuallySized: boolean
|
||||
}
|
||||
|
||||
// These type parameters are defined on the original interface.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface AbstractColDef<TData, TValue> {
|
||||
field: string
|
||||
}
|
||||
}
|
280
app/gui2/src/components/visualizations/events.ts
Normal file
@ -0,0 +1,280 @@
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
proxyRefs,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect,
|
||||
type Ref,
|
||||
type WatchSource,
|
||||
} from 'vue'
|
||||
|
||||
/**
|
||||
* Add an event listener for the duration of the component's lifetime.
|
||||
* @param target element on which to register the event
|
||||
* @param event name of event to register
|
||||
* @param handler event handler
|
||||
*/
|
||||
export function useEvent<K extends keyof DocumentEventMap>(
|
||||
target: Document,
|
||||
event: K,
|
||||
handler: (e: DocumentEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEvent<K extends keyof WindowEventMap>(
|
||||
target: Window,
|
||||
event: K,
|
||||
handler: (e: WindowEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEvent<K extends keyof ElementEventMap>(
|
||||
target: Element,
|
||||
event: K,
|
||||
handler: (event: ElementEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEvent(
|
||||
target: EventTarget,
|
||||
event: string,
|
||||
handler: (event: unknown) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void {
|
||||
onMounted(() => {
|
||||
target.addEventListener(event, handler, options)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
target.removeEventListener(event, handler, options)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event listener for the duration of condition being true.
|
||||
* @param target element on which to register the event
|
||||
* @param condition the condition that determines if event is bound
|
||||
* @param event name of event to register
|
||||
* @param handler event handler
|
||||
*/
|
||||
export function useEventConditional<K extends keyof DocumentEventMap>(
|
||||
target: Document,
|
||||
event: K,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (e: DocumentEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEventConditional<K extends keyof WindowEventMap>(
|
||||
target: Window,
|
||||
event: K,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (e: WindowEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEventConditional<K extends keyof ElementEventMap>(
|
||||
target: Element,
|
||||
event: K,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (event: ElementEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEventConditional(
|
||||
target: EventTarget,
|
||||
event: string,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (event: unknown) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
export function useEventConditional(
|
||||
target: EventTarget,
|
||||
event: string,
|
||||
condition: WatchSource<boolean>,
|
||||
handler: (event: unknown) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void {
|
||||
watch(condition, (conditionMet, _, onCleanup) => {
|
||||
if (conditionMet) {
|
||||
target.addEventListener(event, handler, options)
|
||||
onCleanup(() => target.removeEventListener(event, handler, options))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DOM node size and keep it up to date.
|
||||
*
|
||||
* # Warning:
|
||||
* Updating DOM node layout based on values derived from their size can introduce unwanted feedback
|
||||
* loops across the script and layout reflow. Avoid doing that.
|
||||
*
|
||||
* @param elementRef DOM node to observe.
|
||||
* @returns Reactive value with the DOM node size.
|
||||
*/
|
||||
export function useResizeObserver(
|
||||
elementRef: Ref<Element | undefined | null>,
|
||||
useContentRect = true,
|
||||
): Ref<Size> {
|
||||
const sizeRef = shallowRef<Size>({ width: 0, height: 0 })
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
let rect: Size | null = null
|
||||
for (const entry of entries) {
|
||||
if (entry.target === elementRef.value) {
|
||||
if (useContentRect) {
|
||||
rect = entry.contentRect
|
||||
} else {
|
||||
rect = entry.target.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rect != null) {
|
||||
sizeRef.value = { width: rect.width, height: rect.height }
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const element = elementRef.value
|
||||
if (element != null) {
|
||||
observer.observe(element)
|
||||
onCleanup(() => {
|
||||
if (elementRef.value != null) {
|
||||
observer.unobserve(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return sizeRef
|
||||
}
|
||||
|
||||
export interface EventPosition {
|
||||
/** The event position at the initialization of the drag. */
|
||||
initial: Position
|
||||
/** Absolute event position, equivalent to clientX/Y. */
|
||||
absolute: Position
|
||||
/** Event position relative to the initial position. Total movement of the drag so far. */
|
||||
relative: Position
|
||||
/** Difference of the event position since last event. */
|
||||
delta: Position
|
||||
}
|
||||
|
||||
type PointerEventType = 'start' | 'move' | 'stop'
|
||||
|
||||
/**
|
||||
* A mask of all available pointer buttons. The values are compatible with DOM's `PointerEvent.buttons` value. The mask values
|
||||
* can be ORed together to create a mask of multiple buttons.
|
||||
*/
|
||||
export const enum PointerButtonMask {
|
||||
/** No buttons are pressed. */
|
||||
Empty = 0,
|
||||
/** Main mouse button, usually left. */
|
||||
Main = 1,
|
||||
/** Secondary mouse button, usually right. */
|
||||
Secondary = 2,
|
||||
/** Auxiliary mouse button, usually middle or wheel press. */
|
||||
Auxiliary = 4,
|
||||
/** Additional fourth mouse button, usually assigned to "browser back" action. */
|
||||
ExtBack = 8,
|
||||
/** Additional fifth mouse button, usually assigned to "browser forward" action. */
|
||||
ExtForward = 16,
|
||||
}
|
||||
|
||||
/**
|
||||
* Register for a pointer dragging events.
|
||||
*
|
||||
* @param handler callback on any pointer event
|
||||
* @param requiredButtonMask declare which buttons to look for. The value represents a `PointerEvent.buttons` mask.
|
||||
* @returns
|
||||
*/
|
||||
export function usePointer(
|
||||
handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void,
|
||||
requiredButtonMask: number = PointerButtonMask.Main,
|
||||
) {
|
||||
const trackedPointer: Ref<number | null> = ref(null)
|
||||
let trackedElement: Element | null = null
|
||||
let initialGrabPos: Position | null = null
|
||||
let lastPos: Position | null = null
|
||||
|
||||
const isTracking = () => trackedPointer.value != null
|
||||
|
||||
function doStop(e: PointerEvent) {
|
||||
if (trackedElement != null && trackedPointer.value != null) {
|
||||
trackedElement.releasePointerCapture(trackedPointer.value)
|
||||
}
|
||||
|
||||
trackedPointer.value = null
|
||||
|
||||
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||
handler(computePosition(e, initialGrabPos, lastPos), e, 'stop')
|
||||
lastPos = null
|
||||
trackedElement = null
|
||||
}
|
||||
}
|
||||
|
||||
function doMove(e: PointerEvent) {
|
||||
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||
handler(computePosition(e, initialGrabPos, lastPos), e, 'move')
|
||||
lastPos = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
}
|
||||
|
||||
useEventConditional(window, 'pointerup', isTracking, (e: PointerEvent) => {
|
||||
if (trackedPointer.value === e.pointerId) {
|
||||
e.preventDefault()
|
||||
doStop(e)
|
||||
}
|
||||
})
|
||||
|
||||
useEventConditional(window, 'pointermove', isTracking, (e: PointerEvent) => {
|
||||
if (trackedPointer.value === e.pointerId) {
|
||||
e.preventDefault()
|
||||
// handle release of all masked buttons as stop
|
||||
if ((e.buttons & requiredButtonMask) != 0) {
|
||||
doMove(e)
|
||||
} else {
|
||||
doStop(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const events = {
|
||||
pointerdown(e: PointerEvent) {
|
||||
// pointers should not respond to unmasked mouse buttons
|
||||
if ((e.buttons & requiredButtonMask) == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (trackedPointer.value == null && e.currentTarget instanceof Element) {
|
||||
e.preventDefault()
|
||||
trackedPointer.value = e.pointerId
|
||||
trackedElement = e.currentTarget
|
||||
trackedElement.setPointerCapture(e.pointerId)
|
||||
initialGrabPos = { x: e.clientX, y: e.clientY }
|
||||
lastPos = initialGrabPos
|
||||
handler(computePosition(e, initialGrabPos, lastPos), e, 'start')
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return proxyRefs({
|
||||
events,
|
||||
dragging: computed(() => trackedPointer.value != null),
|
||||
})
|
||||
}
|
||||
|
||||
function computePosition(event: PointerEvent, initial: Position, last: Position): EventPosition {
|
||||
return {
|
||||
initial,
|
||||
absolute: { x: event.clientX, y: event.clientY },
|
||||
relative: { x: event.clientX - initial.x, y: event.clientY - initial.y },
|
||||
delta: { x: event.clientX - last.x, y: event.clientY - last.y },
|
||||
}
|
||||
}
|
25
app/gui2/src/components/visualizations/measurement.ts
Normal file
@ -0,0 +1,25 @@
|
||||
function error(message: string): never {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
let _measureContext: CanvasRenderingContext2D | undefined
|
||||
function getMeasureContext() {
|
||||
return (_measureContext ??=
|
||||
document.createElement('canvas').getContext('2d') ?? error('Could not get canvas 2D context.'))
|
||||
}
|
||||
|
||||
/** Helper function to get text width to make sure that labels on the x axis do not overlap,
|
||||
* and keeps it readable. */
|
||||
export function getTextWidth(
|
||||
text: string | null | undefined,
|
||||
fontSize = '11.5px',
|
||||
fontFamily = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
) {
|
||||
if (text == null) {
|
||||
return 0
|
||||
}
|
||||
const context = getMeasureContext()
|
||||
context.font = `${fontSize} ${fontFamily}`
|
||||
const metrics = context.measureText(' ' + text)
|
||||
return metrics.width
|
||||
}
|
1
app/gui2/src/providers/useVisualizationConfig.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useVisualizationConfig } from '@/providers/visualizationConfig.ts'
|
27
app/gui2/src/providers/visualizationConfig.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { Vec2 } from '@/util/vec2'
|
||||
import { inject, provide, type InjectionKey, type Ref } from 'vue'
|
||||
|
||||
export interface VisualizationConfig {
|
||||
/** Possible visualization types that can be switched to. */
|
||||
background?: string
|
||||
readonly types: string[]
|
||||
readonly isCircularMenuVisible: boolean
|
||||
readonly nodeSize: Vec2
|
||||
width: number | null
|
||||
height: number | null
|
||||
fullscreen: boolean
|
||||
hide: () => void
|
||||
updateType: (type: string) => void
|
||||
}
|
||||
|
||||
const provideKey = Symbol('visualizationConfig') as InjectionKey<Ref<VisualizationConfig>>
|
||||
|
||||
export function useVisualizationConfig(): Ref<VisualizationConfig> {
|
||||
const injected = inject(provideKey)
|
||||
if (injected == null) throw new Error('AppConfig not provided')
|
||||
return injected
|
||||
}
|
||||
|
||||
export function provideVisualizationConfig(visualizationConfig: Ref<VisualizationConfig>) {
|
||||
provide(provideKey, visualizationConfig)
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
@ -104,11 +104,11 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
const exprRange: ContentRange = [stmt.exprOffset, stmt.exprOffset + stmt.expression.length]
|
||||
|
||||
if (affectedRanges != null) {
|
||||
while (affectedRanges[0]?.[1] < exprRange[0]) {
|
||||
while (affectedRanges[0]?.[1]! < exprRange[0]) {
|
||||
affectedRanges.shift()
|
||||
}
|
||||
if (affectedRanges.length === 0) break
|
||||
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0])
|
||||
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0]!)
|
||||
if (!nodeAffected) continue
|
||||
}
|
||||
|
||||
@ -374,7 +374,7 @@ function walkSpansBfs(
|
||||
if (visitChildren?.(span, spanOffset) !== false) {
|
||||
let offset = spanOffset
|
||||
for (let i = 0; i < span.children.length; i++) {
|
||||
const child = span.children[i]
|
||||
const child = span.children[i]!
|
||||
stack.push([child, offset])
|
||||
offset += child.length
|
||||
}
|
||||
|
@ -92,7 +92,7 @@ export interface SuggestionEntry {
|
||||
/// A name of a custom icon to use when displaying the entry.
|
||||
iconName?: string
|
||||
/// A name of a group this entry belongs to.
|
||||
groupIndex?: number
|
||||
groupIndex?: number | undefined
|
||||
}
|
||||
|
||||
function makeSimpleEntry(
|
||||
|
356
app/gui2/src/stores/visualization.ts
Normal file
@ -0,0 +1,356 @@
|
||||
import * as vue from 'vue'
|
||||
import { type DefineComponent } from 'vue'
|
||||
|
||||
import * as vueUseCore from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
import * as useVisualizationConfig from '@/providers/useVisualizationConfig'
|
||||
import type {
|
||||
AddImportNotification,
|
||||
AddRawImportNotification,
|
||||
AddStyleNotification,
|
||||
AddURLImportNotification,
|
||||
CompilationErrorResponse,
|
||||
CompilationResultResponse,
|
||||
CompileError,
|
||||
CompileRequest,
|
||||
FetchError,
|
||||
InvalidMimetypeError,
|
||||
RegisterBuiltinModulesRequest,
|
||||
} from '@/workers/visualizationCompiler'
|
||||
import Compiler from '@/workers/visualizationCompiler?worker'
|
||||
|
||||
const moduleCache: Record<string, any> = {
|
||||
vue,
|
||||
'@vueuse/core': vueUseCore,
|
||||
'builtins/VisualizationContainer.vue': { default: VisualizationContainer },
|
||||
'builtins/useVisualizationConfig.ts': useVisualizationConfig,
|
||||
}
|
||||
// @ts-expect-error Intentionally not defined in `env.d.ts` as it is a mistake to access anywhere
|
||||
// else.
|
||||
window.__visualizationModules = moduleCache
|
||||
|
||||
export type Visualization = DefineComponent<
|
||||
{ data: {} },
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
'update:preprocessor': (module: string, method: string, ...args: string[]) => void
|
||||
}
|
||||
>
|
||||
type VisualizationModule = {
|
||||
default: Visualization
|
||||
name: string
|
||||
inputType: string
|
||||
scripts?: string[]
|
||||
styles?: string[]
|
||||
}
|
||||
|
||||
const builtinVisualizationImports: Record<string, () => Promise<VisualizationModule>> = {
|
||||
JSON: () => import('@/components/visualizations/JSONVisualization.vue') as any,
|
||||
Table: () => import('@/components/visualizations/TableVisualization.vue') as any,
|
||||
Histogram: () => import('@/components/visualizations/HistogramVisualization.vue') as any,
|
||||
Heatmap: () => import('@/components/visualizations/HeatmapVisualization.vue') as any,
|
||||
'SQL Query': () => import('@/components/visualizations/SQLVisualization.vue') as any,
|
||||
Image: () => import('@/components/visualizations/ImageBase64Visualization.vue') as any,
|
||||
Warnings: () => import('@/components/visualizations/WarningsVisualization.vue') as any,
|
||||
}
|
||||
|
||||
const dynamicVisualizationPaths: Record<string, string> = {
|
||||
Test: '/visualizations/TestVisualization.vue',
|
||||
Scatterplot: '/visualizations/ScatterplotVisualization.vue',
|
||||
'Geo Map': '/visualizations/GeoMapVisualization.vue',
|
||||
}
|
||||
|
||||
export const useVisualizationStore = defineStore('visualization', () => {
|
||||
// TODO [sb]: Figure out how to list visualizations defined by a project.
|
||||
const imports = { ...builtinVisualizationImports }
|
||||
const paths = { ...dynamicVisualizationPaths }
|
||||
let cache: Record<string, VisualizationModule> = {}
|
||||
const types = [...Object.keys(imports), ...Object.keys(paths)]
|
||||
let worker: Worker | undefined
|
||||
let workerMessageId = 0
|
||||
const workerCallbacks: Record<
|
||||
string,
|
||||
{ resolve: (result: VisualizationModule) => void; reject: () => void }
|
||||
> = {}
|
||||
|
||||
function register(module: VisualizationModule) {
|
||||
console.log(`registering visualization: name=${module.name}, inputType=${module.inputType}`)
|
||||
}
|
||||
|
||||
function postMessage<T>(worker: Worker, message: T) {
|
||||
worker.postMessage(message)
|
||||
}
|
||||
|
||||
async function compile(path: string) {
|
||||
if (worker == null) {
|
||||
worker = new Compiler()
|
||||
postMessage<RegisterBuiltinModulesRequest>(worker, {
|
||||
type: 'register-builtin-modules-request',
|
||||
modules: Object.keys(moduleCache),
|
||||
})
|
||||
worker.addEventListener(
|
||||
'message',
|
||||
async (
|
||||
event: MessageEvent<
|
||||
// === Responses ===
|
||||
| CompilationResultResponse
|
||||
| CompilationErrorResponse
|
||||
// === Notifications ===
|
||||
| AddStyleNotification
|
||||
| AddRawImportNotification
|
||||
| AddURLImportNotification
|
||||
| AddImportNotification
|
||||
// === Errors ===
|
||||
| FetchError
|
||||
| InvalidMimetypeError
|
||||
| CompileError
|
||||
>,
|
||||
) => {
|
||||
switch (event.data.type) {
|
||||
// === Responses ===
|
||||
case 'compilation-result-response': {
|
||||
workerCallbacks[event.data.id]?.resolve(moduleCache[event.data.path])
|
||||
break
|
||||
}
|
||||
case 'compilation-error-response': {
|
||||
console.error(`Error compiling visualization '${event.data.path}':`, event.data.error)
|
||||
workerCallbacks[event.data.id]?.reject()
|
||||
break
|
||||
}
|
||||
// === Notifications ===
|
||||
case 'add-style-notification': {
|
||||
const styleNode = document.createElement('style')
|
||||
styleNode.innerHTML = event.data.code
|
||||
document.head.appendChild(styleNode)
|
||||
break
|
||||
}
|
||||
case 'add-raw-import-notification': {
|
||||
moduleCache[event.data.path] = event.data.value
|
||||
break
|
||||
}
|
||||
case 'add-url-import-notification': {
|
||||
moduleCache[event.data.path] = {
|
||||
default: URL.createObjectURL(
|
||||
new Blob([event.data.value], { type: event.data.mimeType }),
|
||||
),
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'add-import-notification': {
|
||||
const module = import(
|
||||
/* @vite-ignore */
|
||||
URL.createObjectURL(new Blob([event.data.code], { type: 'text/javascript' }))
|
||||
)
|
||||
moduleCache[event.data.path] = module
|
||||
moduleCache[event.data.path] = await module
|
||||
break
|
||||
}
|
||||
// === Errors ===
|
||||
case 'fetch-error': {
|
||||
console.error(`Error fetching '${event.data.path}':`, event.data.error)
|
||||
break
|
||||
}
|
||||
case 'invalid-mimetype-error': {
|
||||
console.error(
|
||||
`Expected mimetype of '${event.data.path}' to be '${event.data.expected}', ` +
|
||||
`but received '${event.data.actual}' instead`,
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'compile-error': {
|
||||
console.error(`Error compiling '${event.data.path}':`, event.data.error)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
worker.addEventListener('error', (event) => {
|
||||
console.error(event.error)
|
||||
})
|
||||
}
|
||||
const id = workerMessageId
|
||||
workerMessageId += 1
|
||||
const promise = new Promise<VisualizationModule>((resolve, reject) => {
|
||||
workerCallbacks[id] = { resolve, reject }
|
||||
})
|
||||
postMessage<CompileRequest>(worker, { type: 'compile-request', id, path })
|
||||
return await promise
|
||||
}
|
||||
|
||||
const scriptsNode = document.head.appendChild(document.createElement('div'))
|
||||
scriptsNode.classList.add('visualization-scripts')
|
||||
const loadedScripts = new Set<string>()
|
||||
function loadScripts(module: VisualizationModule) {
|
||||
const promises: Promise<void>[] = []
|
||||
if ('scripts' in module && module.scripts) {
|
||||
if (!Array.isArray(module.scripts)) {
|
||||
console.warn('Visualiation scripts should be an array:', module.scripts)
|
||||
}
|
||||
const scripts = Array.isArray(module.scripts) ? module.scripts : [module.scripts]
|
||||
for (const url of scripts) {
|
||||
if (typeof url !== 'string') {
|
||||
console.warn('Visualization script should be a string, skipping URL:', url)
|
||||
} else if (!loadedScripts.has(url)) {
|
||||
loadedScripts.add(url)
|
||||
const node = document.createElement('script')
|
||||
node.src = url
|
||||
promises.push(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
node.addEventListener('load', () => {
|
||||
resolve()
|
||||
})
|
||||
node.addEventListener('error', () => {
|
||||
reject()
|
||||
})
|
||||
}),
|
||||
)
|
||||
scriptsNode.appendChild(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
// NOTE: Because visualization scripts are cached, they are not guaranteed to be up to date.
|
||||
async function get(type: string) {
|
||||
let module = cache[type]
|
||||
if (module == null) {
|
||||
module = await imports[type]?.()
|
||||
}
|
||||
if (module == null) {
|
||||
const path = paths[type]
|
||||
if (path != null) {
|
||||
module = await compile(path)
|
||||
}
|
||||
}
|
||||
if (module == null) {
|
||||
return
|
||||
}
|
||||
register(module)
|
||||
await loadScripts(module)
|
||||
cache[type] = module
|
||||
return module.default
|
||||
}
|
||||
|
||||
function clear() {
|
||||
cache = {}
|
||||
}
|
||||
|
||||
function sampleData(type: string) {
|
||||
switch (type) {
|
||||
case 'Warnings': {
|
||||
return ['warning 1', "warning 2!!&<>;'\x22"]
|
||||
}
|
||||
case 'Image': {
|
||||
return {
|
||||
mediaType: 'image/svg+xml',
|
||||
base64: `PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0\
|
||||
MCI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBkPSJNMjAuMDUgMEEyMCAyMCAwIDAgMCAwIDIwLjA1IDIwLjA2IDIwLjA\
|
||||
2IDAgMSAwIDIwLjA1IDBabTAgMzYuMDVjLTguOTMgMC0xNi4xLTcuMTctMTYuMS0xNi4xIDAtOC45NCA3LjE3LTE2LjEgMTYuMS\
|
||||
0xNi4xIDguOTQgMCAxNi4xIDcuMTYgMTYuMSAxNi4xYTE2LjE4IDE2LjE4IDAgMCAxLTE2LjEgMTYuMVoiLz48cGF0aCBkPSJNM\
|
||||
jcuMTIgMTcuNzdhNC42OCA0LjY4IDAgMCAxIDIuMzkgNS45MiAxMC4yMiAxMC4yMiAwIDAgMS05LjU2IDYuODZBMTAuMiAxMC4y\
|
||||
IDAgMCAxIDkuNzcgMjAuMzZzMS41NSAyLjA4IDQuNTcgMi4wOGMzLjAxIDAgNC4zNi0xLjE0IDUuNi0yLjA4IDEuMjUtLjkzIDI\
|
||||
uMDktMyA1LjItMyAuNzMgMCAxLjQ2LjIgMS45OC40WiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9Ii\
|
||||
NmZmYiIGQ9Ik0wIDBoNDB2NDBIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=`,
|
||||
}
|
||||
}
|
||||
case 'JSON':
|
||||
case 'Scatterplot':
|
||||
case 'Scatterplot 2': {
|
||||
return {
|
||||
axis: {
|
||||
x: { label: 'x-axis label', scale: 'linear' },
|
||||
y: { label: 'y-axis label', scale: 'logarithmic' },
|
||||
},
|
||||
focus: { x: 1.7, y: 2.1, zoom: 3.0 },
|
||||
points: { labels: 'visible' },
|
||||
data: [
|
||||
{ x: 0.1, y: 0.7, label: 'foo', color: 'FF0000', shape: 'circle', size: 0.2 },
|
||||
{ x: 0.4, y: 0.2, label: 'baz', color: '0000FF', shape: 'square', size: 0.3 },
|
||||
],
|
||||
}
|
||||
}
|
||||
case 'Geo Map':
|
||||
case 'Geo Map 2': {
|
||||
return {
|
||||
latitude: 37.8,
|
||||
longitude: -122.45,
|
||||
zoom: 15,
|
||||
controller: true,
|
||||
showingLabels: true, // Enables presenting labels when hovering over a point.
|
||||
layers: [
|
||||
{
|
||||
type: 'Scatterplot_Layer',
|
||||
data: [
|
||||
{
|
||||
latitude: 37.8,
|
||||
longitude: -122.45,
|
||||
color: [255, 0, 0],
|
||||
radius: 100,
|
||||
label: 'an example label',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
case 'Heatmap': {
|
||||
return [
|
||||
['a', 'thing', 'c', 'd', 'a'],
|
||||
[1, 2, 3, 2, 3],
|
||||
[50, 25, 40, 20, 10],
|
||||
]
|
||||
}
|
||||
case 'Histogram': {
|
||||
return {
|
||||
axis: {
|
||||
x: { label: 'x-axis label', scale: 'linear' },
|
||||
y: { label: 'y-axis label', scale: 'logarithmic' },
|
||||
},
|
||||
focus: { x: 1.7, y: 2.1, zoom: 3.0 },
|
||||
color: 'rgb(1.0,0.0,0.0)',
|
||||
bins: 10,
|
||||
data: {
|
||||
values: [0.1, 0.2, 0.1, 0.15, 0.7],
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'Table': {
|
||||
return {
|
||||
type: 'Matrix',
|
||||
// eslint-disable-next-line camelcase
|
||||
column_count: 5,
|
||||
// eslint-disable-next-line camelcase
|
||||
all_rows_count: 10,
|
||||
json: Array.from({ length: 10 }, (_, i) =>
|
||||
Array.from({ length: 5 }, (_, j) => `${i},${j}`),
|
||||
),
|
||||
}
|
||||
}
|
||||
case 'SQL Query': {
|
||||
return {
|
||||
dialect: 'sql',
|
||||
code: `SELECT * FROM \`foo\` WHERE \`a\` = ? AND b LIKE ?;`,
|
||||
interpolations: [
|
||||
// eslint-disable-next-line camelcase
|
||||
{ enso_type: 'Data.Numbers.Number', value: '123' },
|
||||
// eslint-disable-next-line camelcase
|
||||
{ enso_type: 'Builtins.Main.Text', value: "a'bcd" },
|
||||
],
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { types, get, sampleData, clear }
|
||||
})
|
@ -1,5 +1,5 @@
|
||||
import { watchSourceToRef } from '@/util/reactivity'
|
||||
import { onUnmounted, proxyRefs, ref, watch, type WatchSource } from 'vue'
|
||||
import { watchSourceToRef } from './reactivity'
|
||||
|
||||
const rafCallbacks: { fn: (t: number, dt: number) => void; priority: number }[] = []
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import type { Opt } from '@/util/opt'
|
||||
import { watchEffect, type Ref } from 'vue'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import * as Y from 'yjs'
|
||||
import type { Opt } from './opt'
|
||||
|
||||
export function useObserveYjs<T>(
|
||||
typeRef: Ref<Opt<Y.AbstractType<T>>>,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
@ -10,7 +11,6 @@ import {
|
||||
type Ref,
|
||||
type WatchSource,
|
||||
} from 'vue'
|
||||
import { Vec2 } from './vec2'
|
||||
|
||||
/**
|
||||
* Add an event listener on an {@link HTMLElement} for the duration of the component's lifetime.
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { NonEmptyArray } from '@/util/array'
|
||||
import type { Opt } from '@/util/opt'
|
||||
import init, { parse_to_json } from '../../rust-ffi/pkg/rust_ffi'
|
||||
import type { NonEmptyArray } from './array'
|
||||
import type { Opt } from './opt'
|
||||
|
||||
const _wasm = await init()
|
||||
|
||||
|
425
app/gui2/src/workers/visualizationCompiler.ts
Normal file
@ -0,0 +1,425 @@
|
||||
/**
|
||||
* This Web Worker compiles visualizations in a background thread.
|
||||
*
|
||||
* # High-Level Overview
|
||||
* Imports are recursively compiled.
|
||||
* - Unknown imports, are preserved as-is.
|
||||
* - Compiled imports are added to a cache on the main thread.
|
||||
* - Compiled imports are re-written into an object destructure, so that the cache can be used.
|
||||
* - Uses `compiler-sfc` to compile Vue files into TypeScript + CSS, then `sucrase` to compile
|
||||
* the resulting TypeScript into JavaScript.
|
||||
* - Uses `sucrase` to compile TypeScript files into JavaScript.
|
||||
* - Converts SVG files into imports in which the `default` export is the raw text of the SVG image.
|
||||
*
|
||||
* # Typical Request Lifetime
|
||||
* See the "Protocol" section below for details on specific messages.
|
||||
* - A `CompileRequest` is sent with id `1` and path `/Viz.vue`.
|
||||
* - (begin `importVue`) The Worker `fetch`es the path.
|
||||
* - The CSS styles are compiled using `vue/compiler-sfc`, then sent as `AddStyleNotification`s.
|
||||
* - The Vue script is compiled using `vue/compiler-sfc` into TypeScript.
|
||||
* - The TypeScript is compiled using `sucrase` into JavaScript.
|
||||
* - (`rewriteImports`) Imports are analyzed and rewritten as required:
|
||||
* - (`importSvg`) SVG imports are fetched and sent using an `AddRawImportNotification`.
|
||||
* - (`importVue`) Vue imports are recursively compiled as described in this process.
|
||||
* - (`importTS`) TypeScript imports are recursively compiled as described in this process,
|
||||
* excluding the style and script compilation steps.
|
||||
* - (end `importVue`) An `AddUrlNotification` with path `/Viz.vue` is sent to the main
|
||||
* thread.
|
||||
* - A `CompilationResultResponse` with id `1` and path `/Viz.vue` is sent to the main thread. */
|
||||
|
||||
import { parse as babelParse } from '@babel/parser'
|
||||
import hash from 'hash-sum'
|
||||
import MagicString from 'magic-string'
|
||||
import { transform } from 'sucrase'
|
||||
import { compileScript, compileStyle, parse } from 'vue/compiler-sfc'
|
||||
|
||||
// ========================================
|
||||
// === Requests (Main Thread to Worker) ===
|
||||
// ========================================
|
||||
|
||||
/** A request to compile a visualization module. The Worker MUST reply with a
|
||||
* {@link CompilationResultResponse} when compilation is done, or a {@link CompilationErrorResponse}
|
||||
* when compilation fails. The `id` is an arbitrary number that uniquely identifies the request.
|
||||
* The `path` is either an absolute URL (`http://doma.in/path/to/TheScript.vue`), or a root-relative
|
||||
* URL (`/visualizations/TheScript.vue`). Relative URLs (`./TheScript.vue`) are NOT valid.
|
||||
*
|
||||
* Note that compiling files other than Vue files (TypeScript, SVG etc.) are currently NOT
|
||||
* supported. */
|
||||
export interface CompileRequest {
|
||||
type: 'compile-request'
|
||||
id: number
|
||||
path: string
|
||||
}
|
||||
|
||||
/** A request to mark modules as built-in, indicating that the compiler should re-write the imports
|
||||
* into object destructures. */
|
||||
export interface RegisterBuiltinModulesRequest {
|
||||
type: 'register-builtin-modules-request'
|
||||
modules: string[]
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// === Responses (Worker to Main Thread) ===
|
||||
// =========================================
|
||||
|
||||
// These are messages sent in response to a query. They contain the `id` of the original query.
|
||||
|
||||
/** Sent in response to a {@link CompileRequest}, with an `id` matching the `id` of the original
|
||||
* request. Contains only the `path` of the resulting file (which should have also been sent in the
|
||||
* {@link CompileRequest}).
|
||||
* The content itself will have been sent earlier as an {@link AddImportNotification}. */
|
||||
export interface CompilationResultResponse {
|
||||
type: 'compilation-result-response'
|
||||
id: number
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Sent in response to a {@link CompileRequest}, with an `id` matching the `id` of the original
|
||||
* request. Contains the `path` of the resulting file (which should have also been sent in the
|
||||
* {@link CompileRequest}), and the `error` thrown during compilation. */
|
||||
export interface CompilationErrorResponse {
|
||||
type: 'compilation-error-response'
|
||||
id: number
|
||||
path: string
|
||||
error: Error
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// === Notifications (Worker to Main Thread) ===
|
||||
// =============================================
|
||||
|
||||
// These are sent when a subtask successfully completes execution.
|
||||
|
||||
/** Sent after compiling `<style>` and `<style scoped>` sections.
|
||||
* These should be attached to the DOM - placement does not matter. */
|
||||
export interface AddStyleNotification {
|
||||
type: 'add-style-notification'
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Currently unused.
|
||||
*
|
||||
* Sent after compiling an import which does not result in a URL.
|
||||
*
|
||||
* Should be added to the cache using `cache[path] = value`. */
|
||||
export interface AddRawImportNotification {
|
||||
type: 'add-raw-import-notification'
|
||||
path: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/** Sent after compiling an import which results in a URL as its default export.
|
||||
* This is usually the case for assets.
|
||||
*
|
||||
* Should be added to the cache using
|
||||
* `cache[path] = { default: URL.createObjectURL(new Blob([value], { type: mimeType })) }`. */
|
||||
export interface AddURLImportNotification {
|
||||
type: 'add-url-import-notification'
|
||||
path: string
|
||||
mimeType: string
|
||||
value: BlobPart
|
||||
}
|
||||
|
||||
/** Sent after compiling a JavaScript import.
|
||||
*
|
||||
* Should be added to the cache using
|
||||
* `cache[path] = import(URL.createObjectURL(new Blob([code], { type: 'text/javascript' })))`. */
|
||||
export interface AddImportNotification {
|
||||
type: 'add-import-notification'
|
||||
path: string
|
||||
code: string
|
||||
}
|
||||
|
||||
// ======================================
|
||||
// === Errors (Worker to Main Thread) ===
|
||||
// ======================================
|
||||
|
||||
// These are sent when a subtask fails to complete execution.
|
||||
|
||||
/** Sent when the `fetch` call errored, or returned a failure HTTP status code (any code other than
|
||||
* 200-299). */
|
||||
export interface FetchError {
|
||||
type: 'fetch-error'
|
||||
path: string
|
||||
error: Error
|
||||
}
|
||||
|
||||
/** Sent when the `fetch` call succeeded, but returned a response with an unexpected type. */
|
||||
export interface InvalidMimetypeError {
|
||||
type: 'invalid-mimetype-error'
|
||||
path: string
|
||||
expected: string
|
||||
actual: string
|
||||
}
|
||||
|
||||
/** Sent when compilation of a TypeScript or Vue script failed. */
|
||||
export interface CompileError {
|
||||
type: 'compile-error'
|
||||
path: string
|
||||
error: Error
|
||||
}
|
||||
|
||||
// ================
|
||||
// === Compiler ===
|
||||
// ================
|
||||
|
||||
let builtinModules = new Set<string>()
|
||||
const alreadyCompiledModules = new Set<string>()
|
||||
|
||||
const assetMimetypes: Record<string, string> = {
|
||||
// === Image formats ===
|
||||
svg: 'image/svg+xml',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
jpe: 'image/jpeg',
|
||||
jif: 'image/jpeg',
|
||||
jfif: 'image/jpeg',
|
||||
jfi: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
apng: 'image/apng',
|
||||
|
||||
// === Audio formats ===
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/ogg',
|
||||
oga: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
m4p: 'audio/mp4',
|
||||
m4b: 'audio/mp4',
|
||||
m4r: 'audio/mp4',
|
||||
|
||||
// === Video formats ===
|
||||
ogv: 'video/ogg',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
qt: 'video/quicktime',
|
||||
mp4: 'video/mp4',
|
||||
m4v: 'video/mp4',
|
||||
'3gp': 'video/3gpp',
|
||||
'3g2': 'video/3gpp2',
|
||||
}
|
||||
|
||||
function extractExtension(path: string) {
|
||||
return path.match(/(?<=^|[.])[^.]+?(?=[#?]|$)/)?.[0] ?? ''
|
||||
}
|
||||
|
||||
const postMessage: <T>(message: T) => void = globalThis.postMessage
|
||||
|
||||
function addStyle(code: string) {
|
||||
postMessage<AddStyleNotification>({ type: 'add-style-notification', code })
|
||||
}
|
||||
// This is defined to allow for future expansion.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function addRawImport(path: string, value: unknown) {
|
||||
postMessage<AddRawImportNotification>({ type: 'add-raw-import-notification', path, value })
|
||||
}
|
||||
function addUrlImport(path: string, mimeType: string, value: BlobPart) {
|
||||
postMessage<AddURLImportNotification>({
|
||||
type: 'add-url-import-notification',
|
||||
path,
|
||||
mimeType,
|
||||
value,
|
||||
})
|
||||
}
|
||||
function addImport(path: string, code: string) {
|
||||
postMessage<AddImportNotification>({ type: 'add-import-notification', path, code })
|
||||
}
|
||||
|
||||
function fetchError(path: string, error: Error) {
|
||||
postMessage<FetchError>({ type: 'fetch-error', path, error })
|
||||
}
|
||||
function invalidMimetypeError(path: string, expected: string, actual: string) {
|
||||
postMessage<InvalidMimetypeError>({ type: 'invalid-mimetype-error', path, expected, actual })
|
||||
}
|
||||
function compileError(path: string, error: Error) {
|
||||
postMessage<CompileError>({ type: 'compile-error', path, error })
|
||||
}
|
||||
|
||||
async function tryFetch(path: string) {
|
||||
try {
|
||||
const response = await fetch(path)
|
||||
if (!response.ok) {
|
||||
fetchError(
|
||||
path,
|
||||
new Error(`\`fetch\` returned status code ${response.status} (${response.statusText})`),
|
||||
)
|
||||
}
|
||||
return response
|
||||
} catch (error: any) {
|
||||
fetchError(path, error)
|
||||
return new Response()
|
||||
}
|
||||
}
|
||||
|
||||
async function importAsset(path: string, mimeType: string) {
|
||||
const response = await tryFetch(path)
|
||||
const actualMimeType = response.headers.get('Content-Type')?.toLowerCase()
|
||||
if (actualMimeType != null && actualMimeType != mimeType) {
|
||||
invalidMimetypeError(path, mimeType, actualMimeType)
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
addUrlImport(path, mimeType, blob)
|
||||
}
|
||||
|
||||
async function importTS(path: string) {
|
||||
const dir = path.replace(/[^/\\]+$/, '')
|
||||
const scriptTs = await (await tryFetch(path)).text()
|
||||
let text: string | undefined
|
||||
try {
|
||||
text = transform(scriptTs, {
|
||||
disableESTransforms: true,
|
||||
transforms: ['typescript'],
|
||||
}).code
|
||||
} catch (error: any) {
|
||||
compileError(path, error)
|
||||
}
|
||||
if (text != null) {
|
||||
addImport(path, await rewriteImports(text, dir, undefined))
|
||||
}
|
||||
}
|
||||
|
||||
async function importVue(path: string) {
|
||||
const dir = path.replace(/[^/\\]+$/, '')
|
||||
const raw = await (await tryFetch(path)).text()
|
||||
const filename = path.match(/[^/\\]+$/)?.[0]!
|
||||
const parsed = parse(raw, { filename })
|
||||
const id = hash(raw)
|
||||
for (const style of parsed.descriptor.styles) {
|
||||
addStyle(
|
||||
compileStyle({ filename, source: style.content, id, scoped: style.scoped ?? false }).code,
|
||||
)
|
||||
}
|
||||
let text: string | undefined
|
||||
try {
|
||||
const scriptTs = compileScript(parsed.descriptor, {
|
||||
id,
|
||||
inlineTemplate: true,
|
||||
sourceMap: false,
|
||||
}).content
|
||||
text = transform(scriptTs, {
|
||||
disableESTransforms: true,
|
||||
transforms: ['typescript'],
|
||||
}).code
|
||||
} catch (error: any) {
|
||||
compileError(path, error)
|
||||
}
|
||||
if (text != null) {
|
||||
addImport(path, await rewriteImports(text, dir, id))
|
||||
}
|
||||
}
|
||||
|
||||
async function rewriteImports(code: string, dir: string, id: string | undefined) {
|
||||
const ast = babelParse(code, { sourceType: 'module' })
|
||||
const s = new MagicString(code)
|
||||
for (const stmt of ast.program.body) {
|
||||
switch (stmt.type) {
|
||||
case 'ImportDeclaration': {
|
||||
let path = stmt.source.extra!.rawValue as string
|
||||
const isBuiltin = builtinModules.has(path)
|
||||
const isRelative = /^[./]/.test(path)
|
||||
if (isRelative) {
|
||||
path = new URL(dir + path, location.href).toString()
|
||||
}
|
||||
const extension = isRelative ? extractExtension(path).toLowerCase() : ''
|
||||
if (
|
||||
isBuiltin ||
|
||||
(isRelative && (extension in assetMimetypes || extension === 'ts' || extension === 'vue'))
|
||||
) {
|
||||
let namespace: string | undefined
|
||||
const specifiers = stmt.specifiers.flatMap((s: any) => {
|
||||
if (s.type === 'ImportDefaultSpecifier') {
|
||||
return [`default: ${s.local.name}`]
|
||||
} else if (s.type === 'ImportNamespaceSpecifier') {
|
||||
namespace = s.local.name
|
||||
return []
|
||||
} else {
|
||||
if (s.imported.start === s.local.start) {
|
||||
return [s.imported.loc.identifierName]
|
||||
} else {
|
||||
return [`${s.imported.loc.identifierName}: ${s.local.loc.identifierName}`]
|
||||
}
|
||||
}
|
||||
})
|
||||
const pathJSON = JSON.stringify(path)
|
||||
const destructureExpression = `{ ${specifiers.join(', ')} }`
|
||||
const rewritten =
|
||||
namespace != null
|
||||
? `const ${namespace} = await window.__visualizationModules[${pathJSON}];` +
|
||||
(specifiers.length > 0 ? `\nconst ${destructureExpression} = ${namespace};` : '')
|
||||
: `const ${destructureExpression} = await window.__visualizationModules[${pathJSON}];`
|
||||
s.overwrite(stmt.start!, stmt.end!, rewritten)
|
||||
if (isBuiltin) {
|
||||
// No further action is needed.
|
||||
} else {
|
||||
if (alreadyCompiledModules.has(path)) {
|
||||
continue
|
||||
}
|
||||
alreadyCompiledModules.add(path)
|
||||
switch (extension) {
|
||||
case 'ts': {
|
||||
await importTS(path)
|
||||
break
|
||||
}
|
||||
case 'vue': {
|
||||
await importVue(path)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const mimetype = assetMimetypes[extension]
|
||||
if (mimetype != null) {
|
||||
await importAsset(path, mimetype)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ExportDefaultDeclaration': {
|
||||
if (id != null && (stmt.declaration as any)?.callee?.name === '_defineComponent') {
|
||||
const firstProp = (stmt.declaration as any)?.arguments?.[0]?.properties?.[0]
|
||||
if (firstProp?.start != null) {
|
||||
s.appendLeft(firstProp.start, `__scopeId: ${JSON.stringify(`data-v-${id}`)}, `)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.toString()
|
||||
}
|
||||
|
||||
onmessage = async (event: MessageEvent<RegisterBuiltinModulesRequest | CompileRequest>) => {
|
||||
switch (event.data.type) {
|
||||
case 'register-builtin-modules-request': {
|
||||
builtinModules = new Set(event.data.modules)
|
||||
break
|
||||
}
|
||||
case 'compile-request': {
|
||||
try {
|
||||
if (!alreadyCompiledModules.has(event.data.path)) {
|
||||
alreadyCompiledModules.add(event.data.path)
|
||||
await importVue(event.data.path)
|
||||
}
|
||||
postMessage<CompilationResultResponse>({
|
||||
type: 'compilation-result-response',
|
||||
id: event.data.id,
|
||||
path: event.data.path,
|
||||
})
|
||||
} catch (error: any) {
|
||||
postMessage<CompilationErrorResponse>({
|
||||
type: 'compilation-error-response',
|
||||
id: event.data.id,
|
||||
path: event.data.path,
|
||||
error,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
@ -1,15 +1,29 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.json", "src/**/*.vue", "shared/**/*"],
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"shared/**/*",
|
||||
"shared/**/*.vue",
|
||||
"public/**/*",
|
||||
"public/**/*.vue"
|
||||
],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"resolvePackageJsonExports": false,
|
||||
"composite": true,
|
||||
"outDir": "../../node_modules/.cache/tsc",
|
||||
"baseUrl": ".",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"types": ["vitest/importMeta"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"builtins/VisualizationContainer.vue": ["./src/components/VisualizationContainer.vue"],
|
||||
"builtins/useVisualizationConfig.ts": ["./src/providers/useVisualizationConfig.ts"]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
|
@ -28,7 +28,7 @@ export default defineConfig({
|
||||
define: {
|
||||
REDIRECT_OVERRIDE: JSON.stringify('http://localhost:8080'),
|
||||
PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl),
|
||||
global: 'window',
|
||||
global: 'globalThis',
|
||||
IS_DEV_MODE: JSON.stringify(process.env.NODE_ENV !== 'production'),
|
||||
},
|
||||
assetsInclude: ['**/*.yaml', '**/*.svg'],
|
||||
|
@ -1,5 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"lib": ["ES2015", "DOM"],
|
||||
"skipLibCheck": false
|
||||
},
|
||||
"extends": "../../tsconfig.json"
|
||||
|
476
package-lock.json
generated
@ -28,14 +28,19 @@
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.22.16",
|
||||
"@open-rpc/client-js": "^1.8.1",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"enso-authentication": "^1.0.0",
|
||||
"hash-sum": "^2.0.0",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lib0": "^0.2.83",
|
||||
"magic-string": "^0.30.3",
|
||||
"pinia": "^2.1.6",
|
||||
"postcss-inline-svg": "^6.0.0",
|
||||
"postcss-nesting": "^12.0.1",
|
||||
"sha3": "^2.1.4",
|
||||
"sucrase": "^3.34.0",
|
||||
"vue": "^3.3.4",
|
||||
"ws": "^8.13.0",
|
||||
"y-protocols": "^1.0.5",
|
||||
@ -44,12 +49,16 @@
|
||||
"yjs": "^13.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@danmarshall/deckgl-typings": "^4.9.28",
|
||||
"@eslint/eslintrc": "^2.1.2",
|
||||
"@eslint/js": "^8.49.0",
|
||||
"@playwright/test": "^1.37.0",
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
"@tsconfig/node18": "^18.2.0",
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/hash-sum": "^1.0.0",
|
||||
"@types/jsdom": "^21.1.1",
|
||||
"@types/mapbox-gl": "^2.7.13",
|
||||
"@types/node": "^18.17.5",
|
||||
"@types/shuffle-seed": "^1.1.0",
|
||||
"@types/ws": "^8.5.5",
|
||||
@ -60,6 +69,8 @@
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"ag-grid-community": "^30.1.0",
|
||||
"ag-grid-enterprise": "^30.1.0",
|
||||
"esbuild": "^0.19.3",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-plugin-vue": "^9.16.1",
|
||||
@ -68,6 +79,7 @@
|
||||
"prettier": "^3.0.0",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"shuffle-seed": "^1.1.6",
|
||||
"sql-formatter": "^13.0.0",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^4.4.9",
|
||||
@ -1432,6 +1444,18 @@
|
||||
"postcss-selector-parser": "^6.0.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@danmarshall/deckgl-typings": {
|
||||
"version": "4.9.28",
|
||||
"resolved": "https://registry.npmjs.org/@danmarshall/deckgl-typings/-/deckgl-typings-4.9.28.tgz",
|
||||
"integrity": "sha512-cvp0sPunaOgzI/6Kb9zQjNPOegFrli8t/mWLESTDarZT1xBGe9FwLQ9wQT0XFcVagdlhe2NFBx0oeRy0L4f1GQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@types/hammerjs": "^2.0.36",
|
||||
"@types/react": "*",
|
||||
"indefinitely-typed": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"dev": true,
|
||||
@ -2576,7 +2600,6 @@
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.0.1",
|
||||
@ -2589,7 +2612,6 @@
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@ -2597,7 +2619,6 @@
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@ -2609,7 +2630,6 @@
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.19",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@ -2981,6 +3001,259 @@
|
||||
"version": "0.3.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.0.tgz",
|
||||
"integrity": "sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.7.tgz",
|
||||
"integrity": "sha512-4/Q0FckQ8TBjsB0VdGFemJOG8BLXUB2KKlL0VmZ+eOYeOnTb/wDRQqYWpBmQ6IlvWkXwkYiot+n9Px2aTJ7zGQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.3.tgz",
|
||||
"integrity": "sha512-SE3x/pLO/+GIHH17mvs1uUVPkZ3bHquGzvZpPAh4yadRy71J93MJBpgK/xY8l9gT28yTN1g9v3HfGSFeBMmwZw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.3.tgz",
|
||||
"integrity": "sha512-MQ1/M/B5ifTScHSe5koNkhxn2mhUPqXjGuKjjVYckplAPjP9t2I2sZafb/YVHDwhoXWZoSav+Q726eIbN3qprA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.3.tgz",
|
||||
"integrity": "sha512-keuSRwO02c7PBV3JMWuctIfdeJrVFI7RpzouehvBWL4/GGUB3PBNg/9ZKPZAgJphzmS2v2+7vr7BGDQw1CAulw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-contour": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.3.tgz",
|
||||
"integrity": "sha512-x7G/tdDZt4m09XZnG2SutbIuQqmkNYqR9uhDMdPlpJbcwepkEjEWG29euFcgVA1k6cn92CHdDL9Z+fOnxnbVQw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
|
||||
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.3.tgz",
|
||||
"integrity": "sha512-Df7KW3Re7G6cIpIhQtqHin8yUxUHYAqiE41ffopbmU5+FifYUNV7RVyTg8rQdkEagg83m14QtS8InvNb95Zqug==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.3.tgz",
|
||||
"integrity": "sha512-82AuQMpBQjuXeIX4tjCYfWjpm3g7aGCfx6dFlxX2JlRaiME/QWcHzBsINl7gbHCODA2anPYlL31/Trj/UnjK9A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.2.tgz",
|
||||
"integrity": "sha512-DooW5AOkj4AGmseVvbwHvwM/Ltu0Ks0WrhG6r5FG9riHT5oUUTHz6xHsHqJSVU8ZmPkOqlUEY2obS5C9oCIi2g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.0.tgz",
|
||||
"integrity": "sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.3.tgz",
|
||||
"integrity": "sha512-/EsDKRiQkby3Z/8/AiZq8bsuLDo/tYHnNIZkUpSeEHWV7fHUl6QFBjvMPbhkKGk9jZutzfOkGygCV7eR/MkcXA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.5.tgz",
|
||||
"integrity": "sha512-EGG+IWx93ESSXBwfh/5uPuR9Hp8M6o6qEGU7bBQslxCvrdUBQZha/EFpu/VMdLU4B0y4Oe4h175nSm7p9uqFug==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
|
||||
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.0.4.tgz",
|
||||
"integrity": "sha512-kmUK8rVVIBPKJ1/v36bk2aSgwRj2N/ZkjDT+FkMT5pgedZoPlyhaG62J+9EgNIgUXE6IIL0b7bkLxCzhE6U4VQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.3.tgz",
|
||||
"integrity": "sha512-GpSK308Xj+HeLvogfEc7QsCOcIxkDwLhFYnOoohosEzOqv7/agxwvJER1v/kTC+CY1nfazR0F7gnHo7GE41/fw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz",
|
||||
"integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.0.tgz",
|
||||
"integrity": "sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz",
|
||||
"integrity": "sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.4.tgz",
|
||||
"integrity": "sha512-eq1ZeTj0yr72L8MQk6N6heP603ubnywSDRfNpi5enouR112HzGLS6RIvExCzZTraFF4HdzNpJMwA/zGiMoHUUw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz",
|
||||
"integrity": "sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.6.tgz",
|
||||
"integrity": "sha512-2ACr96USZVjXR9KMD9IWi1Epo4rSDKnUtYn6q2SPhYxykvXTw9vR77lkFNruXVg4i1tzQtBxeDMx0oNvJWbF1w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.2.tgz",
|
||||
"integrity": "sha512-NN4CXr3qeOUNyK5WasVUV8NCSAx/CRVcwcb0BuuS1PiTqwIm6ABi1SyasLZ/vsVCFDArF+W4QiGzSry1eKYQ7w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
|
||||
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.0.tgz",
|
||||
"integrity": "sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz",
|
||||
"integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.4.tgz",
|
||||
"integrity": "sha512-512a4uCOjUzsebydItSXsHrPeQblCVk8IKjqCUmrlvBWkkVh3donTTxmURDo1YPwIVDh5YVwCAO6gR4sgimCPQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.4.tgz",
|
||||
"integrity": "sha512-cqkuY1ah9ZQre2POqjSLcM8g40UVya/qwEUrNYP2/rCVljbmqKCVcv+ebvwhlI5azIbSEL7m+os6n+WlYA43aA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.8",
|
||||
"dev": true,
|
||||
@ -3002,6 +3275,24 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.11.tgz",
|
||||
"integrity": "sha512-L7A0AINMXQpVwxHJ4jxD6/XjZ4NDufaRlUJHjNIFKYUFBH1SvOW+neaqb0VTRSLW5suSrSu19ObFEFnfNcr+qg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/hammerjs": {
|
||||
"version": "2.0.42",
|
||||
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.42.tgz",
|
||||
"integrity": "sha512-Xxk14BrwHnGi0xlURPRb+Y0UNn2w3cTkeFm7pKMsYOaNgH/kabbJLhcBoNIodwsbTz7Z8KcWjtDvlGH0nc0U9w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/hash-sum": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/hash-sum/-/hash-sum-1.0.0.tgz",
|
||||
"integrity": "sha512-FdLBT93h3kcZ586Aee66HPCVJ6qvxVjBlDWNmxSGSbCZe9hTsjRKdSsl4y1T+3zfujxo9auykQMnFsfyHWD7wg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.0.2",
|
||||
"dev": true,
|
||||
@ -3035,6 +3326,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mapbox-gl": {
|
||||
"version": "2.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-2.7.14.tgz",
|
||||
"integrity": "sha512-uoeZncHF7EOGCZTHida8nneFrzBbO5S0Bjvg0AJoGDXpkYkYRN2mq7RK0h+wtXZ/bYbahTFshbLChWBKKWhvyw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "3.0.1",
|
||||
"dev": true,
|
||||
@ -3935,6 +4235,18 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-grid-community": {
|
||||
"version": "30.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-30.1.0.tgz",
|
||||
"integrity": "sha512-D69e63CUALxfgLZSu1rXC8Xiyhu6+17zxzTV8cWsyvt5GeSDv2frQ3BKOqGZbUfVoOCLv2SQoHVTTqw8OjxavA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ag-grid-enterprise": {
|
||||
"version": "30.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-enterprise/-/ag-grid-enterprise-30.1.0.tgz",
|
||||
"integrity": "sha512-bDmdx6A/VjO1w82xHZ0PSHjZcgUeE5CylISrbRNGFKQyBrFyJmiRSVLTqoikpl9Cxg1+oyjlqtCdUmUJIVuQNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"license": "MIT",
|
||||
@ -4037,7 +4349,6 @@
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
@ -5877,6 +6188,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/discontinuous-range": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
|
||||
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/discord-api-types": {
|
||||
"version": "0.37.50",
|
||||
"dev": true,
|
||||
@ -7733,6 +8050,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stdin": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz",
|
||||
"integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "5.2.0",
|
||||
"license": "MIT",
|
||||
@ -8060,6 +8389,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/hash-sum": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
|
||||
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg=="
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"dev": true,
|
||||
@ -8262,6 +8596,51 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/indefinitely-typed": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/indefinitely-typed/-/indefinitely-typed-1.1.0.tgz",
|
||||
"integrity": "sha512-giaI0hCj+wWZIZZLsmWHI+LrM4Hwc+rEZ/VrgCafKePcnE42fLnQTFt4xspqLin8fCjI5WnQr2fep/0EFqjaxw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fs-extra": "^7.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"indefinitely-typed": "bin/cli2.js"
|
||||
}
|
||||
},
|
||||
"node_modules/indefinitely-typed/node_modules/fs-extra": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/indefinitely-typed/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/indefinitely-typed/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT",
|
||||
@ -9585,7 +9964,6 @@
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lint": {
|
||||
@ -10053,6 +10431,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/moo": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
|
||||
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
@ -10077,7 +10461,6 @@
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
@ -10116,6 +10499,34 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nearley": {
|
||||
"version": "2.20.1",
|
||||
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
|
||||
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"commander": "^2.19.0",
|
||||
"moo": "^0.5.0",
|
||||
"railroad-diagrams": "^1.0.0",
|
||||
"randexp": "0.4.6"
|
||||
},
|
||||
"bin": {
|
||||
"nearley-railroad": "bin/nearley-railroad.js",
|
||||
"nearley-test": "bin/nearley-test.js",
|
||||
"nearley-unparse": "bin/nearley-unparse.js",
|
||||
"nearleyc": "bin/nearleyc.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://nearley.js.org/#give-to-nearley"
|
||||
}
|
||||
},
|
||||
"node_modules/nearley/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nice-try": {
|
||||
"version": "1.0.5",
|
||||
"dev": true,
|
||||
@ -11090,7 +11501,6 @@
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@ -11593,6 +12003,25 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/railroad-diagrams": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
|
||||
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/randexp": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
|
||||
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"discontinuous-range": "1.0.0",
|
||||
"ret": "~0.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"dev": true,
|
||||
@ -12270,6 +12699,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ret": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
||||
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.0.4",
|
||||
"license": "MIT",
|
||||
@ -13053,6 +13491,20 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sql-formatter": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-13.0.0.tgz",
|
||||
"integrity": "sha512-V21cVvge4rhn9Fa7K/fTKcmPM+x1yee6Vhq8ZwgaWh3VPBqApgsaoFB5kLAhiqRo5AmSaRyLU7LIdgnNwH01/w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"get-stdin": "=8.0.0",
|
||||
"nearley": "^2.20.1"
|
||||
},
|
||||
"bin": {
|
||||
"sql-formatter": "bin/sql-formatter-cli.cjs"
|
||||
}
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.17.0",
|
||||
"license": "MIT",
|
||||
@ -13311,7 +13763,6 @@
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.34.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
@ -13332,7 +13783,6 @@
|
||||
},
|
||||
"node_modules/sucrase/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@ -13341,7 +13791,6 @@
|
||||
},
|
||||
"node_modules/sucrase/node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@ -13349,7 +13798,6 @@
|
||||
},
|
||||
"node_modules/sucrase/node_modules/glob": {
|
||||
"version": "7.1.6",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
@ -13368,7 +13816,6 @@
|
||||
},
|
||||
"node_modules/sucrase/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
@ -13602,7 +14049,6 @@
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
@ -13610,7 +14056,6 @@
|
||||
},
|
||||
"node_modules/thenify-all": {
|
||||
"version": "1.6.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
@ -13791,7 +14236,6 @@
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ts-mixer": {
|
||||
|