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"
This commit is contained in:
somebody1234 2023-09-26 18:14:56 +10:00 committed by GitHub
parent d139449bb6
commit 0a70f2edf5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 6371 additions and 600 deletions

1
app/gui2/.gitignore vendored
View File

@ -9,6 +9,7 @@ dist
dist-ssr
coverage
*.local
*.tsbuildinfo
# Editor directories and files
.vscode/*

6
app/gui2/env.d.ts vendored
View File

@ -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
}

View File

@ -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>

View File

@ -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
}

View File

@ -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",

View 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>

View 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>

View 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 }
},
}

View 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
}
}

View 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 },
}
}

View 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

View File

@ -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

View 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

View 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

View 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

View 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
}

View 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>

View File

@ -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

View File

@ -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;
}

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@ -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

View 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>

View File

@ -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',

View File

@ -183,7 +183,7 @@ class FilteringQualifiedName {
*/
export class Filtering {
pattern?: FilteringWithPattern
selfType?: QualifiedName
selfType?: QualifiedName | undefined
qualifiedName?: FilteringQualifiedName
showUnstable: boolean = false
showLocal: boolean = false

View File

@ -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)

View File

@ -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>

View File

@ -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
>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View 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> = {
'&': '&amp;',
'<': '&lt;',
'"': '&quot;',
"'": '&#39;',
'>': '&gt;',
}
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">&lsaquo;</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">&rsaquo;</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>

View File

@ -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>

View 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 }
},
}

View File

@ -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
}
}

View 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 },
}
}

View 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
}

View File

@ -0,0 +1 @@
export { useVisualizationConfig } from '@/providers/visualizationConfig.ts'

View 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)
}

View File

@ -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 }
})

View File

@ -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
}

View File

@ -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(

View 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 }
})

View File

@ -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 }[] = []

View File

@ -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>>>,

View File

@ -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.

View File

@ -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()

View 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
}
}
}

View File

@ -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": [

View File

@ -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'],

View File

@ -1,5 +1,7 @@
{
"compilerOptions": {
"types": ["node"],
"lib": ["ES2015", "DOM"],
"skipLibCheck": false
},
"extends": "../../tsconfig.json"

476
package-lock.json generated
View File

@ -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": {