mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 23:01:29 +03:00
New visualization UI API (#11077)
- Improve visualization UI APIs: - Isolate visualizations within a Vue Custom Element to prevent any unintended interaction between GUI and visualization CSS/JS. - New visualization-menus API: Visualizations no longer create toolbars using the GUI's components; a simpler JS interface moves the responsibility for appearance of controls to the GUI. - Simplify visualization configuration interface. Properties that should not be exposed to visualizations have been removed. Visualizations no longer need logic implementing fullscreen mode; the `size` property reflects the current renderable area. - Visualizations no longer use a `VisualizationContainer`; the visualization simply renders its content at its root. - Viz dropdowns: Buttons always show arrows (fixes #10809) - Fullscreen mode: Fix rendering size of scatter plot and other visualizations - JSON visualization interactivity: Fix intermittent incorrectly non-interactive state - Viz toolbars: Fix squished-looking rightmost button. Other API changes: - `Interaction` no longer includes `GraphNavigator` with pointer events.
This commit is contained in:
parent
3f748fdf5c
commit
a8810e19f2
@ -95,7 +95,7 @@ function componentLocator(locatorStr: string) {
|
|||||||
|
|
||||||
export const graphEditor = componentLocator('.GraphEditor')
|
export const graphEditor = componentLocator('.GraphEditor')
|
||||||
export const codeEditor = componentLocator('.CodeEditor')
|
export const codeEditor = componentLocator('.CodeEditor')
|
||||||
export const anyVisualization = componentLocator('.GraphVisualization > *')
|
export const anyVisualization = componentLocator('.GraphVisualization')
|
||||||
export const loadingVisualization = componentLocator('.LoadingVisualization')
|
export const loadingVisualization = componentLocator('.LoadingVisualization')
|
||||||
export const circularMenu = componentLocator('.CircularMenu')
|
export const circularMenu = componentLocator('.CircularMenu')
|
||||||
export const addNewNodeButton = componentLocator('.PlusButton')
|
export const addNewNodeButton = componentLocator('.PlusButton')
|
||||||
@ -141,15 +141,26 @@ export function bottomDock(page: Page) {
|
|||||||
|
|
||||||
export const navBreadcrumb = componentLocator('.NavBreadcrumb')
|
export const navBreadcrumb = componentLocator('.NavBreadcrumb')
|
||||||
export const componentBrowserInput = componentLocator('.ComponentEditor')
|
export const componentBrowserInput = componentLocator('.ComponentEditor')
|
||||||
export const jsonVisualization = componentLocator('.JSONVisualization')
|
|
||||||
export const tableVisualization = componentLocator('.TableVisualization')
|
function visualizationLocator(visSelector: string) {
|
||||||
export const scatterplotVisualization = componentLocator('.ScatterplotVisualization')
|
// Playwright pierces shadow roots, but not within a single XPath.
|
||||||
export const histogramVisualization = componentLocator('.HistogramVisualization')
|
// Locate the visualization content, then locate the descendant.
|
||||||
export const heatmapVisualization = componentLocator('.HeatmapVisualization')
|
const visLocator = componentLocator(visSelector)
|
||||||
export const sqlVisualization = componentLocator('.SqlVisualization')
|
return (page: Locator | Page, filter?: (f: Filter) => { selector: string }) => {
|
||||||
export const geoMapVisualization = componentLocator('.GeoMapVisualization')
|
const hostLocator = page.locator('.VisualizationHostContainer')
|
||||||
export const imageBase64Visualization = componentLocator('.ImageBase64Visualization')
|
return visLocator(hostLocator, filter)
|
||||||
export const warningsVisualization = componentLocator('.WarningsVisualization')
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jsonVisualization = visualizationLocator('.JSONVisualization')
|
||||||
|
export const tableVisualization = visualizationLocator('.TableVisualization')
|
||||||
|
export const scatterplotVisualization = visualizationLocator('.ScatterplotVisualization')
|
||||||
|
export const histogramVisualization = visualizationLocator('.HistogramVisualization')
|
||||||
|
export const heatmapVisualization = visualizationLocator('.HeatmapVisualization')
|
||||||
|
export const sqlVisualization = visualizationLocator('.SqlVisualization')
|
||||||
|
export const geoMapVisualization = visualizationLocator('.GeoMapVisualization')
|
||||||
|
export const imageBase64Visualization = visualizationLocator('.ImageBase64Visualization')
|
||||||
|
export const warningsVisualization = visualizationLocator('.WarningsVisualization')
|
||||||
|
|
||||||
// === Edge locators ===
|
// === Edge locators ===
|
||||||
|
|
||||||
|
@ -130,37 +130,30 @@ registerAutoBlurHandler()
|
|||||||
|
|
||||||
:deep(.scrollable) {
|
:deep(.scrollable) {
|
||||||
scrollbar-color: rgba(190 190 190 / 50%) transparent;
|
scrollbar-color: rgba(190 190 190 / 50%) transparent;
|
||||||
}
|
&::-webkit-scrollbar {
|
||||||
|
-webkit-appearance: none;
|
||||||
:deep(.scrollable)::-webkit-scrollbar {
|
}
|
||||||
-webkit-appearance: none;
|
&::-webkit-scrollbar-track {
|
||||||
}
|
-webkit-box-shadow: none;
|
||||||
|
}
|
||||||
:deep(.scrollable)::-webkit-scrollbar-track {
|
&::-webkit-scrollbar:vertical {
|
||||||
-webkit-box-shadow: none;
|
width: 11px;
|
||||||
}
|
}
|
||||||
|
&::-webkit-scrollbar:horizontal {
|
||||||
:deep(.scrollable)::-webkit-scrollbar:vertical {
|
height: 11px;
|
||||||
width: 11px;
|
}
|
||||||
}
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 8px;
|
||||||
:deep(.scrollable)::-webkit-scrollbar:horizontal {
|
border: 1px solid rgba(220, 220, 220, 0.5);
|
||||||
height: 11px;
|
background-color: rgba(190, 190, 190, 0.5);
|
||||||
}
|
}
|
||||||
|
&::-webkit-scrollbar-corner {
|
||||||
:deep(.scrollable)::-webkit-scrollbar-thumb {
|
background: rgba(0, 0, 0, 0);
|
||||||
border-radius: 8px;
|
}
|
||||||
border: 1px solid rgba(220, 220, 220, 0.5);
|
&::-webkit-scrollbar-button {
|
||||||
background-color: rgba(190, 190, 190, 0.5);
|
height: 8px;
|
||||||
}
|
width: 8px;
|
||||||
|
}
|
||||||
:deep(.scrollable)::-webkit-scrollbar-corner {
|
|
||||||
background: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.scrollable)::-webkit-scrollbar-button {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.draggable) {
|
:deep(.draggable) {
|
||||||
|
@ -11,6 +11,7 @@ const open = defineModel<boolean>('open', { default: false })
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title?: string | undefined
|
title?: string | undefined
|
||||||
placement?: Placement
|
placement?: Placement
|
||||||
|
alwaysShowArrow?: boolean | undefined
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const rootElement = shallowRef<HTMLElement>()
|
const rootElement = shallowRef<HTMLElement>()
|
||||||
@ -42,7 +43,11 @@ const { floatingStyles } = useFloating(rootElement, floatElement, {
|
|||||||
>
|
>
|
||||||
<slot name="button" />
|
<slot name="button" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<SvgIcon v-if="hovered && !open" name="arrow_right_head_only" class="arrow" />
|
<SvgIcon
|
||||||
|
v-if="alwaysShowArrow || (hovered && !open)"
|
||||||
|
name="arrow_right_head_only"
|
||||||
|
class="arrow"
|
||||||
|
/>
|
||||||
<SizeTransition height :duration="100">
|
<SizeTransition height :duration="100">
|
||||||
<div v-if="open" ref="floatElement" class="DropdownMenuContent" :style="floatingStyles">
|
<div v-if="open" ref="floatElement" class="DropdownMenuContent" :style="floatingStyles">
|
||||||
<slot name="entries" />
|
<slot name="entries" />
|
||||||
|
@ -279,23 +279,13 @@ useEvent(window, 'keydown', (event) => {
|
|||||||
(!keyboardBusy() && graphNavigator.keyboardEvents.keydown(event))
|
(!keyboardBusy() && graphNavigator.keyboardEvents.keydown(event))
|
||||||
})
|
})
|
||||||
|
|
||||||
useEvent(
|
useEvent(window, 'pointerdown', (e) => interaction.handlePointerEvent(e, 'pointerdown'), {
|
||||||
window,
|
capture: true,
|
||||||
'pointerdown',
|
})
|
||||||
(e) => interaction.handlePointerEvent(e, 'pointerdown', graphNavigator),
|
|
||||||
{
|
|
||||||
capture: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
useEvent(
|
useEvent(window, 'pointerup', (e) => interaction.handlePointerEvent(e, 'pointerup'), {
|
||||||
window,
|
capture: true,
|
||||||
'pointerup',
|
})
|
||||||
(e) => interaction.handlePointerEvent(e, 'pointerup', graphNavigator),
|
|
||||||
{
|
|
||||||
capture: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// === Keyboard/Mouse bindings ===
|
// === Keyboard/Mouse bindings ===
|
||||||
|
|
||||||
|
@ -27,21 +27,20 @@ const MIN_DRAG_MOVE = 10
|
|||||||
const editingEdge: Interaction = {
|
const editingEdge: Interaction = {
|
||||||
cancel: () => (graph.mouseEditedEdge = undefined),
|
cancel: () => (graph.mouseEditedEdge = undefined),
|
||||||
end: () => (graph.mouseEditedEdge = undefined),
|
end: () => (graph.mouseEditedEdge = undefined),
|
||||||
pointerdown: (_e: PointerEvent, graphNavigator: GraphNavigator) =>
|
pointerdown: edgeInteractionClick,
|
||||||
edgeInteractionClick(graphNavigator),
|
pointerup: (e: PointerEvent) => {
|
||||||
pointerup: (e: PointerEvent, graphNavigator: GraphNavigator) => {
|
|
||||||
const originEvent = graph.mouseEditedEdge?.event
|
const originEvent = graph.mouseEditedEdge?.event
|
||||||
if (originEvent?.type === 'pointerdown') {
|
if (originEvent?.type === 'pointerdown') {
|
||||||
const delta = new Vec2(e.screenX, e.screenY).sub(
|
const delta = new Vec2(e.screenX, e.screenY).sub(
|
||||||
new Vec2(originEvent.screenX, originEvent.screenY),
|
new Vec2(originEvent.screenX, originEvent.screenY),
|
||||||
)
|
)
|
||||||
if (delta.lengthSquared() >= MIN_DRAG_MOVE ** 2) return edgeInteractionClick(graphNavigator)
|
if (delta.lengthSquared() >= MIN_DRAG_MOVE ** 2) return edgeInteractionClick()
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function edgeInteractionClick(graphNavigator: GraphNavigator) {
|
function edgeInteractionClick() {
|
||||||
if (graph.mouseEditedEdge == null) return false
|
if (graph.mouseEditedEdge == null) return false
|
||||||
let source: AstId | undefined
|
let source: AstId | undefined
|
||||||
let sourceNode: NodeId | undefined
|
let sourceNode: NodeId | undefined
|
||||||
@ -59,7 +58,7 @@ function edgeInteractionClick(graphNavigator: GraphNavigator) {
|
|||||||
if (target == null) {
|
if (target == null) {
|
||||||
if (graph.mouseEditedEdge?.disconnectedEdgeTarget != null)
|
if (graph.mouseEditedEdge?.disconnectedEdgeTarget != null)
|
||||||
disconnectEdge(graph.mouseEditedEdge.disconnectedEdgeTarget)
|
disconnectEdge(graph.mouseEditedEdge.disconnectedEdgeTarget)
|
||||||
emits('createNodeFromEdge', source, graphNavigator.sceneMousePos ?? Vec2.Zero)
|
emits('createNodeFromEdge', source, props.navigator.sceneMousePos ?? Vec2.Zero)
|
||||||
} else {
|
} else {
|
||||||
createEdge(source, target)
|
createEdge(source, target)
|
||||||
}
|
}
|
||||||
|
@ -1,41 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { visualizationBindings } from '@/bindings'
|
import { visualizationBindings } from '@/bindings'
|
||||||
|
import {
|
||||||
|
RawDataSource,
|
||||||
|
useVisualizationData,
|
||||||
|
} from '@/components/GraphEditor/GraphVisualization/visualizationData'
|
||||||
|
import VisualizationToolbar from '@/components/GraphEditor/GraphVisualization/VisualizationToolbar.vue'
|
||||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||||
import LoadingErrorVisualization from '@/components/visualizations/LoadingErrorVisualization.vue'
|
import ResizeHandles from '@/components/ResizeHandles.vue'
|
||||||
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
|
import WithFullscreenMode from '@/components/WithFullscreenMode.vue'
|
||||||
import { SavedSize } from '@/components/WithFullscreenMode.vue'
|
import { focusIsIn, useEvent, useResizeObserver } from '@/composables/events'
|
||||||
import { focusIsIn, useEvent } from '@/composables/events'
|
import { VisualizationDataSource } from '@/stores/visualization'
|
||||||
import { provideVisualizationConfig } from '@/providers/visualizationConfig'
|
|
||||||
import { useProjectStore } from '@/stores/project'
|
|
||||||
import { type NodeVisualizationConfiguration } from '@/stores/project/executionContext'
|
|
||||||
import {
|
|
||||||
DEFAULT_VISUALIZATION_CONFIGURATION,
|
|
||||||
DEFAULT_VISUALIZATION_IDENTIFIER,
|
|
||||||
useVisualizationStore,
|
|
||||||
type VisualizationDataSource,
|
|
||||||
} from '@/stores/visualization'
|
|
||||||
import type { Visualization } from '@/stores/visualization/runtimeTypes'
|
|
||||||
import { Ast } from '@/util/ast'
|
|
||||||
import { toError } from '@/util/data/error'
|
|
||||||
import type { Opt } from '@/util/data/opt'
|
import type { Opt } from '@/util/data/opt'
|
||||||
import { Rect } from '@/util/data/rect'
|
import { type BoundsSet, Rect } from '@/util/data/rect'
|
||||||
import type { Result } from '@/util/data/result'
|
|
||||||
import type { URLString } from '@/util/data/urlString'
|
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
import type { Icon } from '@/util/iconName'
|
import { computed, nextTick, onUnmounted, ref, toRef, watch, watchEffect } from 'vue'
|
||||||
import { computedAsync } from '@vueuse/core'
|
|
||||||
import {
|
|
||||||
computed,
|
|
||||||
nextTick,
|
|
||||||
onErrorCaptured,
|
|
||||||
onUnmounted,
|
|
||||||
ref,
|
|
||||||
shallowRef,
|
|
||||||
watch,
|
|
||||||
watchEffect,
|
|
||||||
type ShallowRef,
|
|
||||||
} from 'vue'
|
|
||||||
import { isIdentifier } from 'ydoc-shared/ast'
|
|
||||||
import { visIdentifierEquals, type VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
import { visIdentifierEquals, type VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||||
|
|
||||||
/** The minimum width must be at least the total width of:
|
/** The minimum width must be at least the total width of:
|
||||||
@ -44,10 +22,6 @@ import { visIdentifierEquals, type VisualizationIdentifier } from 'ydoc-shared/y
|
|||||||
const MIN_WIDTH_PX = 200
|
const MIN_WIDTH_PX = 200
|
||||||
const MIN_CONTENT_HEIGHT_PX = 32
|
const MIN_CONTENT_HEIGHT_PX = 32
|
||||||
const DEFAULT_CONTENT_HEIGHT_PX = 150
|
const DEFAULT_CONTENT_HEIGHT_PX = 150
|
||||||
const TOOLBAR_HEIGHT_PX = 36
|
|
||||||
|
|
||||||
// Used for testing.
|
|
||||||
type RawDataSource = { type: 'raw'; data: any }
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentType?: Opt<VisualizationIdentifier>
|
currentType?: Opt<VisualizationIdentifier>
|
||||||
@ -74,287 +48,41 @@ const emit = defineEmits<{
|
|||||||
createNodes: [options: NodeCreationOptions[]]
|
createNodes: [options: NodeCreationOptions[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const visPreprocessor = ref(DEFAULT_VISUALIZATION_CONFIGURATION)
|
// ===================================
|
||||||
const vueError = ref<Error>()
|
// === Visualization-Specific Data ===
|
||||||
|
// ===================================
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const {
|
||||||
const visualizationStore = useVisualizationStore()
|
effectiveVisualization,
|
||||||
|
effectiveVisualizationData,
|
||||||
const configForGettingDefaultVisualization = computed<NodeVisualizationConfiguration | undefined>(
|
updatePreprocessor,
|
||||||
() => {
|
allTypes,
|
||||||
if (props.currentType) return
|
currentType,
|
||||||
if (props.dataSource?.type !== 'node') return
|
setToolbarDefinition,
|
||||||
return {
|
visualizationDefinedToolbar,
|
||||||
visualizationModule: 'Standard.Visualization.Helpers',
|
toolbarOverlay,
|
||||||
expression: 'a -> a.default_visualization.to_js_object.to_json',
|
} = useVisualizationData({
|
||||||
expressionId: props.dataSource.nodeId,
|
selectedVis: toRef(props, 'currentType'),
|
||||||
}
|
dataSource: toRef(props, 'dataSource'),
|
||||||
},
|
typename: toRef(props, 'typename'),
|
||||||
)
|
|
||||||
|
|
||||||
const defaultVisualizationRaw = projectStore.useVisualizationData(
|
|
||||||
configForGettingDefaultVisualization,
|
|
||||||
) as ShallowRef<Result<{ library: { name: string } | null; name: string } | undefined>>
|
|
||||||
|
|
||||||
const defaultVisualizationForCurrentNodeSource = computed<VisualizationIdentifier | undefined>(
|
|
||||||
() => {
|
|
||||||
const raw = defaultVisualizationRaw.value
|
|
||||||
if (!raw?.ok || !raw.value || !raw.value.name) return
|
|
||||||
return {
|
|
||||||
name: raw.value.name,
|
|
||||||
module:
|
|
||||||
raw.value.library == null ?
|
|
||||||
{ kind: 'Builtin' }
|
|
||||||
: { kind: 'Library', name: raw.value.library.name },
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentType = computed(() => {
|
|
||||||
if (props.currentType) return props.currentType
|
|
||||||
if (defaultVisualizationForCurrentNodeSource.value)
|
|
||||||
return defaultVisualizationForCurrentNodeSource.value
|
|
||||||
const [id] = visualizationStore.types(props.typename)
|
|
||||||
return id
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const visualization = shallowRef<Visualization>()
|
// ===========
|
||||||
const icon = ref<Icon | URLString>()
|
// === DOM ===
|
||||||
|
// ===========
|
||||||
|
|
||||||
onErrorCaptured((error) => {
|
/** Includes content and toolbars. */
|
||||||
vueError.value = error
|
const panelElement = ref<HTMLElement>()
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
const nodeVisualizationData = projectStore.useVisualizationData(() => {
|
/** Contains only the visualization itself. */
|
||||||
if (props.dataSource?.type !== 'node') return
|
const contentElement = ref<HTMLElement>()
|
||||||
return {
|
const contentElementSize = useResizeObserver(contentElement)
|
||||||
...visPreprocessor.value,
|
|
||||||
expressionId: props.dataSource.nodeId,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const expressionVisualizationData = computedAsync(() => {
|
// === Events ===
|
||||||
if (props.dataSource?.type !== 'expression') return
|
|
||||||
if (preprocessorLoading.value) return
|
|
||||||
const preprocessor = visPreprocessor.value
|
|
||||||
const args = preprocessor.positionalArgumentsExpressions
|
|
||||||
const tempModule = Ast.MutableModule.Transient()
|
|
||||||
const preprocessorModule = Ast.parse(preprocessor.visualizationModule, tempModule)
|
|
||||||
// TODO[ao]: it work with builtin visualization, but does not work in general case.
|
|
||||||
// Tracked in https://github.com/orgs/enso-org/discussions/6832#discussioncomment-7754474.
|
|
||||||
if (!isIdentifier(preprocessor.expression)) {
|
|
||||||
console.error(`Unsupported visualization preprocessor definition`, preprocessor)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const preprocessorQn = Ast.PropertyAccess.new(
|
|
||||||
tempModule,
|
|
||||||
preprocessorModule,
|
|
||||||
preprocessor.expression,
|
|
||||||
)
|
|
||||||
const preprocessorInvocation = Ast.App.PositionalSequence(preprocessorQn, [
|
|
||||||
Ast.Wildcard.new(tempModule),
|
|
||||||
...args.map((arg) => Ast.Group.new(tempModule, Ast.parse(arg, tempModule))),
|
|
||||||
])
|
|
||||||
const rhs = Ast.parse(props.dataSource.expression, tempModule)
|
|
||||||
const expression = Ast.OprApp.new(tempModule, preprocessorInvocation, '<|', rhs)
|
|
||||||
return projectStore.executeExpression(props.dataSource.contextId, expression.code())
|
|
||||||
})
|
|
||||||
|
|
||||||
const effectiveVisualizationData = computed(() => {
|
|
||||||
const name = currentType.value?.name
|
|
||||||
if (props.dataSource?.type === 'raw') return props.dataSource.data
|
|
||||||
if (vueError.value) return { name, error: vueError.value }
|
|
||||||
const visualizationData = nodeVisualizationData.value ?? expressionVisualizationData.value
|
|
||||||
if (!visualizationData) return
|
|
||||||
if (visualizationData.ok) return visualizationData.value
|
|
||||||
else return { name, error: new Error(`${visualizationData.error.payload}`) }
|
|
||||||
})
|
|
||||||
|
|
||||||
function updatePreprocessor(
|
|
||||||
visualizationModule: string,
|
|
||||||
expression: string,
|
|
||||||
...positionalArgumentsExpressions: string[]
|
|
||||||
) {
|
|
||||||
visPreprocessor.value = { visualizationModule, expression, positionalArgumentsExpressions }
|
|
||||||
}
|
|
||||||
// Required to work around janky Vue definitions for the type of a Visualization
|
|
||||||
const updatePreprocessor_ = updatePreprocessor as (...args: unknown[]) => void
|
|
||||||
|
|
||||||
function switchToDefaultPreprocessor() {
|
|
||||||
visPreprocessor.value = DEFAULT_VISUALIZATION_CONFIGURATION
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [currentType.value, visualization.value],
|
|
||||||
() => (vueError.value = undefined),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Flag used to prevent rendering the visualization with a stale preprocessor while the new preprocessor is being
|
|
||||||
// prepared asynchronously.
|
|
||||||
const preprocessorLoading = ref(false)
|
|
||||||
watchEffect(async () => {
|
|
||||||
preprocessorLoading.value = true
|
|
||||||
if (currentType.value == null) return
|
|
||||||
visualization.value = undefined
|
|
||||||
icon.value = undefined
|
|
||||||
try {
|
|
||||||
const module = await visualizationStore.get(currentType.value).value
|
|
||||||
if (module) {
|
|
||||||
if (module.defaultPreprocessor != null) {
|
|
||||||
updatePreprocessor(...module.defaultPreprocessor)
|
|
||||||
} else {
|
|
||||||
switchToDefaultPreprocessor()
|
|
||||||
}
|
|
||||||
visualization.value = module.default
|
|
||||||
icon.value = module.icon
|
|
||||||
} else {
|
|
||||||
switch (currentType.value.module.kind) {
|
|
||||||
case 'Builtin': {
|
|
||||||
vueError.value = new Error(
|
|
||||||
`The builtin visualization '${currentType.value.name}' was not found.`,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'CurrentProject': {
|
|
||||||
vueError.value = new Error(
|
|
||||||
`The visualization '${currentType.value.name}' was not found in the current project.`,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'Library': {
|
|
||||||
vueError.value = new Error(
|
|
||||||
`The visualization '${currentType.value.name}' was not found in the library '${currentType.value.module.name}'.`,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (caughtError) {
|
|
||||||
vueError.value = toError(caughtError)
|
|
||||||
}
|
|
||||||
preprocessorLoading.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
const isBelowToolbar = ref(false)
|
|
||||||
|
|
||||||
const toolbarHeight = computed(() => (isBelowToolbar.value ? TOOLBAR_HEIGHT_PX : 0))
|
|
||||||
|
|
||||||
const rect = computed(
|
|
||||||
() =>
|
|
||||||
new Rect(
|
|
||||||
props.nodePosition,
|
|
||||||
new Vec2(
|
|
||||||
Math.max(props.width ?? MIN_WIDTH_PX, props.nodeSize.x),
|
|
||||||
Math.max(props.height ?? DEFAULT_CONTENT_HEIGHT_PX, MIN_CONTENT_HEIGHT_PX) +
|
|
||||||
toolbarHeight.value +
|
|
||||||
props.nodeSize.y,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
watchEffect(() => emit('update:rect', rect.value))
|
|
||||||
onUnmounted(() => emit('update:rect', undefined))
|
|
||||||
|
|
||||||
const allTypes = computed(() => Array.from(visualizationStore.types(props.typename)))
|
|
||||||
|
|
||||||
const isFullscreen = ref(false)
|
|
||||||
const currentSavedSize = ref<SavedSize>()
|
|
||||||
|
|
||||||
provideVisualizationConfig({
|
|
||||||
get isFocused() {
|
|
||||||
return props.isFocused
|
|
||||||
},
|
|
||||||
get fullscreen() {
|
|
||||||
return isFullscreen.value
|
|
||||||
},
|
|
||||||
set fullscreen(value) {
|
|
||||||
isFullscreen.value = value
|
|
||||||
},
|
|
||||||
get isFullscreenAllowed() {
|
|
||||||
return props.isFullscreenAllowed
|
|
||||||
},
|
|
||||||
get isResizable() {
|
|
||||||
return props.isResizable
|
|
||||||
},
|
|
||||||
get savedSize() {
|
|
||||||
return currentSavedSize.value
|
|
||||||
},
|
|
||||||
set savedSize(value) {
|
|
||||||
currentSavedSize.value = value
|
|
||||||
},
|
|
||||||
get scale() {
|
|
||||||
return props.scale
|
|
||||||
},
|
|
||||||
get width() {
|
|
||||||
return rect.value.width
|
|
||||||
},
|
|
||||||
set width(value) {
|
|
||||||
emit('update:width', value)
|
|
||||||
},
|
|
||||||
get height() {
|
|
||||||
return rect.value.height - toolbarHeight.value - props.nodeSize.y
|
|
||||||
},
|
|
||||||
set height(value) {
|
|
||||||
emit('update:height', value)
|
|
||||||
},
|
|
||||||
get nodePosition() {
|
|
||||||
return props.nodePosition
|
|
||||||
},
|
|
||||||
set nodePosition(value) {
|
|
||||||
emit('update:nodePosition', value)
|
|
||||||
},
|
|
||||||
get isBelowToolbar() {
|
|
||||||
return isBelowToolbar.value
|
|
||||||
},
|
|
||||||
set isBelowToolbar(value) {
|
|
||||||
isBelowToolbar.value = value
|
|
||||||
},
|
|
||||||
get types() {
|
|
||||||
return allTypes.value
|
|
||||||
},
|
|
||||||
get isCircularMenuVisible() {
|
|
||||||
return props.isCircularMenuVisible
|
|
||||||
},
|
|
||||||
get nodeSize() {
|
|
||||||
return props.nodeSize
|
|
||||||
},
|
|
||||||
get currentType() {
|
|
||||||
return currentType.value ?? DEFAULT_VISUALIZATION_IDENTIFIER
|
|
||||||
},
|
|
||||||
get icon() {
|
|
||||||
return icon.value
|
|
||||||
},
|
|
||||||
get nodeType() {
|
|
||||||
return props.typename
|
|
||||||
},
|
|
||||||
get isPreview() {
|
|
||||||
return props.isPreview ?? false
|
|
||||||
},
|
|
||||||
hide: () => emit('update:enabled', false),
|
|
||||||
updateType: (id) => emit('update:id', id),
|
|
||||||
createNodes: (...options) => emit('createNodes', options),
|
|
||||||
})
|
|
||||||
|
|
||||||
const effectiveVisualization = computed(() => {
|
|
||||||
if (
|
|
||||||
vueError.value ||
|
|
||||||
(nodeVisualizationData.value && !nodeVisualizationData.value.ok) ||
|
|
||||||
(expressionVisualizationData.value && !expressionVisualizationData.value.ok)
|
|
||||||
) {
|
|
||||||
return LoadingErrorVisualization
|
|
||||||
}
|
|
||||||
if (!visualization.value || effectiveVisualizationData.value == null) {
|
|
||||||
return LoadingVisualization
|
|
||||||
}
|
|
||||||
return visualization.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const root = ref<HTMLElement>()
|
|
||||||
|
|
||||||
const keydownHandler = visualizationBindings.handler({
|
const keydownHandler = visualizationBindings.handler({
|
||||||
nextType: () => {
|
nextType: () => {
|
||||||
if (props.isFocused || focusIsIn(root.value)) {
|
if (props.isFocused || focusIsIn(panelElement.value)) {
|
||||||
const currentIndex = allTypes.value.findIndex((type) =>
|
const currentIndex = allTypes.value.findIndex((type) =>
|
||||||
visIdentifierEquals(type, currentType.value),
|
visIdentifierEquals(type, currentType.value),
|
||||||
)
|
)
|
||||||
@ -365,7 +93,7 @@ const keydownHandler = visualizationBindings.handler({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleFullscreen: () => {
|
toggleFullscreen: () => {
|
||||||
if (props.isFocused || focusIsIn(root.value)) {
|
if (props.isFocused || focusIsIn(panelElement.value)) {
|
||||||
isFullscreen.value = !isFullscreen.value
|
isFullscreen.value = !isFullscreen.value
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
@ -382,30 +110,196 @@ const keydownHandler = visualizationBindings.handler({
|
|||||||
|
|
||||||
useEvent(window, 'keydown', keydownHandler)
|
useEvent(window, 'keydown', keydownHandler)
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent) {
|
||||||
|
if (
|
||||||
|
event.currentTarget instanceof Element &&
|
||||||
|
(isFullscreen.value ||
|
||||||
|
event.currentTarget.scrollWidth > event.currentTarget.clientWidth ||
|
||||||
|
event.currentTarget.scrollHeight > event.currentTarget.clientHeight)
|
||||||
|
) {
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================
|
||||||
|
// === Sizing and Fullscreen ===
|
||||||
|
// =============================
|
||||||
|
|
||||||
|
const rect = computed(
|
||||||
|
() =>
|
||||||
|
new Rect(
|
||||||
|
props.nodePosition,
|
||||||
|
new Vec2(
|
||||||
|
Math.max(props.width ?? MIN_WIDTH_PX, props.nodeSize.x),
|
||||||
|
Math.max(props.height ?? DEFAULT_CONTENT_HEIGHT_PX, MIN_CONTENT_HEIGHT_PX) +
|
||||||
|
props.nodeSize.y,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
watchEffect(() => emit('update:rect', rect.value))
|
||||||
|
onUnmounted(() => emit('update:rect', undefined))
|
||||||
|
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
|
||||||
|
const containerContentSize = computed<Vec2>(
|
||||||
|
() => new Vec2(rect.value.width, rect.value.height - props.nodeSize.y),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Because ResizeHandles are applying the screen mouse movements, the bounds must be in `screen`
|
||||||
|
// space.
|
||||||
|
const clientBounds = computed({
|
||||||
|
get() {
|
||||||
|
return new Rect(Vec2.Zero, containerContentSize.value.scale(props.scale))
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
if (resizing.left || resizing.right) emit('update:width', value.width / props.scale)
|
||||||
|
if (resizing.bottom) emit('update:height', value.height / props.scale)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let resizing: BoundsSet = {}
|
||||||
|
|
||||||
|
watch(containerContentSize, (newVal, oldVal) => {
|
||||||
|
if (!resizing.left) return
|
||||||
|
const delta = newVal.x - oldVal.x
|
||||||
|
if (delta !== 0)
|
||||||
|
emit('update:nodePosition', new Vec2(props.nodePosition.x - delta, props.nodePosition.y))
|
||||||
|
})
|
||||||
|
|
||||||
|
const style = computed(() => {
|
||||||
|
return {
|
||||||
|
'padding-top': `${props.nodeSize.y}px`,
|
||||||
|
width: `${rect.value.width}px`,
|
||||||
|
height: `${rect.value.height}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fullscreenAnimating = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => isFullscreen,
|
() => isFullscreen,
|
||||||
(f) => {
|
(f) => {
|
||||||
f && nextTick(() => root.value?.focus())
|
f && nextTick(() => panelElement.value?.focus())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import VisualizationHost from '@/components/visualizations/VisualizationHost.vue'
|
||||||
|
import { defineCustomElement } from 'vue'
|
||||||
|
|
||||||
|
// ==========================
|
||||||
|
// === Visualization Host ===
|
||||||
|
// ==========================
|
||||||
|
|
||||||
|
let definitionNumber = 0
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.data.graphVizDefinitionNumber =
|
||||||
|
(import.meta.hot.data.graphVizDefinitionNumber ?? 0) + 1
|
||||||
|
definitionNumber = import.meta.hot.data.graphVizDefinitionNumber
|
||||||
|
}
|
||||||
|
const ensoVisualizationHost = `enso-visualization-host-${definitionNumber}`
|
||||||
|
customElements.define(ensoVisualizationHost, defineCustomElement(VisualizationHost))
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="root" class="GraphVisualization" tabindex="-1">
|
<div class="GraphVisualization" :style="style">
|
||||||
<Suspense>
|
<WithFullscreenMode :fullscreen="isFullscreen" @update:animating="fullscreenAnimating = $event">
|
||||||
<template #fallback><LoadingVisualization :data="{}" /></template>
|
<div
|
||||||
<component
|
ref="panelElement"
|
||||||
:is="effectiveVisualization"
|
class="VisualizationPanel"
|
||||||
:data="effectiveVisualizationData"
|
:class="{
|
||||||
@update:preprocessor="updatePreprocessor_"
|
fullscreen: isFullscreen || fullscreenAnimating,
|
||||||
/>
|
nonInteractive: isPreview,
|
||||||
</Suspense>
|
}"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<VisualizationToolbar
|
||||||
|
v-model:isFullscreen="isFullscreen"
|
||||||
|
:currentVis="currentType"
|
||||||
|
:showControls="!isPreview"
|
||||||
|
:hideVisualizationButton="
|
||||||
|
isFullscreen ? 'hide'
|
||||||
|
: isCircularMenuVisible ? 'invisible'
|
||||||
|
: 'show'
|
||||||
|
"
|
||||||
|
:isFullscreenAllowed="isFullscreenAllowed"
|
||||||
|
:allTypes="allTypes"
|
||||||
|
:visualizationDefinedToolbar="visualizationDefinedToolbar"
|
||||||
|
:typename="typename"
|
||||||
|
:class="{ overlay: toolbarOverlay }"
|
||||||
|
@update:currentVis="emit('update:id', $event)"
|
||||||
|
@hide="emit('update:enabled', false)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref="contentElement"
|
||||||
|
class="VisualizationHostContainer content scrollable"
|
||||||
|
@wheel.passive="onWheel"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="ensoVisualizationHost"
|
||||||
|
:visualization="effectiveVisualization"
|
||||||
|
:data="effectiveVisualizationData"
|
||||||
|
:size="contentElementSize"
|
||||||
|
:nodeType="typename"
|
||||||
|
@updatePreprocessor="
|
||||||
|
updatePreprocessor($event.detail[0], $event.detail[1], ...$event.detail.slice(2))
|
||||||
|
"
|
||||||
|
@updateToolbar="setToolbarDefinition($event.detail[0])"
|
||||||
|
@updateToolbarOverlay="toolbarOverlay = $event.detail[0]"
|
||||||
|
@createNodes="emit('createNodes', $event.detail[0])"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WithFullscreenMode>
|
||||||
|
<ResizeHandles
|
||||||
|
v-if="!isPreview && isResizable"
|
||||||
|
v-model="clientBounds"
|
||||||
|
left
|
||||||
|
right
|
||||||
|
bottom
|
||||||
|
@update:resizing="resizing = $event"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.GraphVisualization {
|
.GraphVisualization {
|
||||||
|
--resize-handle-inside: var(--visualization-resize-handle-inside);
|
||||||
|
--resize-handle-outside: var(--visualization-resize-handle-outside);
|
||||||
|
--resize-handle-radius: var(--radius-default);
|
||||||
|
position: absolute;
|
||||||
|
border-radius: var(--radius-default);
|
||||||
|
background: var(--color-visualization-bg);
|
||||||
/** Prevent drawing on top of other UI elements (e.g. dropdown widgets). */
|
/** Prevent drawing on top of other UI elements (e.g. dropdown widgets). */
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.VisualizationPanel {
|
||||||
|
--permanent-toolbar-width: 240px;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
&.fullscreen {
|
||||||
|
background: var(--color-visualization-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
overflow: auto;
|
||||||
|
contain: strict;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nonInteractive {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -0,0 +1,159 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FullscreenButton from '@/components/FullscreenButton.vue'
|
||||||
|
import SelectionDropdown from '@/components/SelectionDropdown.vue'
|
||||||
|
import SvgButton from '@/components/SvgButton.vue'
|
||||||
|
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||||
|
import {
|
||||||
|
isActionButton,
|
||||||
|
isSelectionMenu,
|
||||||
|
isToggleButton,
|
||||||
|
ToolbarItem,
|
||||||
|
} from '@/components/visualizations/toolbar'
|
||||||
|
import VisualizationSelector from '@/components/VisualizationSelector.vue'
|
||||||
|
import { useEvent } from '@/composables/events'
|
||||||
|
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||||
|
import { isQualifiedName, qnLastSegment } from '@/util/qualifiedName'
|
||||||
|
import { computed, toValue } from 'vue'
|
||||||
|
import { VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||||
|
|
||||||
|
const isFullscreen = defineModel<boolean>('isFullscreen', { required: true })
|
||||||
|
const currentVis = defineModel<VisualizationIdentifier>('currentVis', { required: true })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
showControls: boolean
|
||||||
|
hideVisualizationButton: 'show' | 'hide' | 'invisible'
|
||||||
|
isFullscreenAllowed: boolean
|
||||||
|
allTypes: Iterable<VisualizationIdentifier>
|
||||||
|
visualizationDefinedToolbar: Readonly<ToolbarItem[]> | undefined
|
||||||
|
typename: string | undefined
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
hide: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const UNKNOWN_TYPE = 'Unknown'
|
||||||
|
const nodeShortType = computed(() =>
|
||||||
|
props.typename != null && isQualifiedName(props.typename) ?
|
||||||
|
qnLastSegment(props.typename)
|
||||||
|
: UNKNOWN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
const interaction = provideInteractionHandler()
|
||||||
|
useEvent(window, 'pointerdown', (e) => interaction.handlePointerEvent(e, 'pointerdown'), {
|
||||||
|
capture: true,
|
||||||
|
})
|
||||||
|
useEvent(window, 'pointerup', (e) => interaction.handlePointerEvent(e, 'pointerup'), {
|
||||||
|
capture: true,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="VisualizationToolbar">
|
||||||
|
<template v-if="showControls">
|
||||||
|
<div
|
||||||
|
v-if="hideVisualizationButton !== 'hide'"
|
||||||
|
class="toolbar"
|
||||||
|
:class="{ invisible: hideVisualizationButton === 'invisible' }"
|
||||||
|
>
|
||||||
|
<SvgButton name="eye" title="Hide visualization" @click.stop="emit('hide')" />
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<FullscreenButton v-if="isFullscreenAllowed" v-model="isFullscreen" />
|
||||||
|
<VisualizationSelector v-model="currentVis" :types="allTypes" />
|
||||||
|
</div>
|
||||||
|
<div v-if="visualizationDefinedToolbar" class="visualization-defined-toolbars">
|
||||||
|
<div class="toolbar">
|
||||||
|
<template v-for="(item, index) in visualizationDefinedToolbar" :key="index">
|
||||||
|
<SvgButton
|
||||||
|
v-if="isActionButton(item)"
|
||||||
|
:name="item.icon"
|
||||||
|
:title="item.title"
|
||||||
|
:onClick="item.onClick"
|
||||||
|
:disabled="item.disabled != null ? toValue(item.disabled) : false"
|
||||||
|
:data-testid="item.dataTestid"
|
||||||
|
/>
|
||||||
|
<ToggleIcon
|
||||||
|
v-else-if="isToggleButton(item)"
|
||||||
|
v-model="item.toggle.value"
|
||||||
|
:icon="item.icon"
|
||||||
|
:title="item.title"
|
||||||
|
:disabled="item.disabled != null ? toValue(item.disabled) : false"
|
||||||
|
:data-testid="item.dataTestid"
|
||||||
|
/>
|
||||||
|
<SelectionDropdown
|
||||||
|
v-else-if="isSelectionMenu(item)"
|
||||||
|
v-model="item.selected.value"
|
||||||
|
:options="item.options"
|
||||||
|
:title="item.title"
|
||||||
|
alwaysShowArrow
|
||||||
|
/>
|
||||||
|
<div v-else>?</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
class="after-toolbars node-type"
|
||||||
|
:title="props.typename ?? UNKNOWN_TYPE"
|
||||||
|
v-text="nodeShortType"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.VisualizationToolbar {
|
||||||
|
flex: 0;
|
||||||
|
transition-duration: 100ms;
|
||||||
|
transition-property: padding-left;
|
||||||
|
width: 100%;
|
||||||
|
user-select: none;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-toolbars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: calc(var(--node-size-x) - var(--permanent-toolbar-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-type {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
z-index: 20;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-app-bg);
|
||||||
|
backdrop-filter: var(--blur-app-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar:not(:first-child):not(:has(> *)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar > :deep(*) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,243 @@
|
|||||||
|
import LoadingErrorVisualization from '@/components/visualizations/LoadingErrorVisualization.vue'
|
||||||
|
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
|
||||||
|
import { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||||
|
import { useProjectStore } from '@/stores/project'
|
||||||
|
import type { NodeVisualizationConfiguration } from '@/stores/project/executionContext'
|
||||||
|
import {
|
||||||
|
DEFAULT_VISUALIZATION_CONFIGURATION,
|
||||||
|
DEFAULT_VISUALIZATION_IDENTIFIER,
|
||||||
|
useVisualizationStore,
|
||||||
|
VisualizationDataSource,
|
||||||
|
} from '@/stores/visualization'
|
||||||
|
import type { Visualization } from '@/stores/visualization/runtimeTypes'
|
||||||
|
import { Ast } from '@/util/ast'
|
||||||
|
import { toError } from '@/util/data/error'
|
||||||
|
import { ToValue } from '@/util/reactivity'
|
||||||
|
import { computedAsync } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
onErrorCaptured,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
ShallowRef,
|
||||||
|
toValue,
|
||||||
|
watch,
|
||||||
|
watchEffect,
|
||||||
|
} from 'vue'
|
||||||
|
import { isIdentifier } from 'ydoc-shared/ast'
|
||||||
|
import type { Opt } from 'ydoc-shared/util/data/opt'
|
||||||
|
import type { Result } from 'ydoc-shared/util/data/result'
|
||||||
|
import type { VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||||
|
|
||||||
|
// Used for testing.
|
||||||
|
export type RawDataSource = { type: 'raw'; data: any }
|
||||||
|
|
||||||
|
export interface UseVisualizationDataOptions {
|
||||||
|
selectedVis: ToValue<Opt<VisualizationIdentifier>>
|
||||||
|
typename: ToValue<string | undefined>
|
||||||
|
dataSource: ToValue<VisualizationDataSource | RawDataSource | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVisualizationData({
|
||||||
|
selectedVis,
|
||||||
|
dataSource,
|
||||||
|
typename,
|
||||||
|
}: UseVisualizationDataOptions) {
|
||||||
|
const visPreprocessor = ref(DEFAULT_VISUALIZATION_CONFIGURATION)
|
||||||
|
const vueError = ref<Error>()
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
const visualizationStore = useVisualizationStore()
|
||||||
|
|
||||||
|
const configForGettingDefaultVisualization = computed<NodeVisualizationConfiguration | undefined>(
|
||||||
|
() => {
|
||||||
|
if (toValue(selectedVis)) return
|
||||||
|
const dataSourceValue = toValue(dataSource)
|
||||||
|
if (dataSourceValue?.type !== 'node') return
|
||||||
|
return {
|
||||||
|
visualizationModule: 'Standard.Visualization.Helpers',
|
||||||
|
expression: 'a -> a.default_visualization.to_js_object.to_json',
|
||||||
|
expressionId: dataSourceValue.nodeId,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultVisualizationRaw = projectStore.useVisualizationData(
|
||||||
|
configForGettingDefaultVisualization,
|
||||||
|
) as ShallowRef<Result<{ library: { name: string } | null; name: string } | undefined>>
|
||||||
|
|
||||||
|
const defaultVisualizationForCurrentNodeSource = computed<VisualizationIdentifier | undefined>(
|
||||||
|
() => {
|
||||||
|
const raw = defaultVisualizationRaw.value
|
||||||
|
if (!raw?.ok || !raw.value || !raw.value.name) return
|
||||||
|
return {
|
||||||
|
name: raw.value.name,
|
||||||
|
module:
|
||||||
|
raw.value.library == null ?
|
||||||
|
{ kind: 'Builtin' }
|
||||||
|
: { kind: 'Library', name: raw.value.library.name },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentType = computed(() => {
|
||||||
|
const selectedTypeValue = toValue(selectedVis)
|
||||||
|
if (selectedTypeValue) return selectedTypeValue
|
||||||
|
if (defaultVisualizationForCurrentNodeSource.value)
|
||||||
|
return defaultVisualizationForCurrentNodeSource.value
|
||||||
|
const [id] = visualizationStore.types(toValue(typename))
|
||||||
|
return id ?? DEFAULT_VISUALIZATION_IDENTIFIER
|
||||||
|
})
|
||||||
|
|
||||||
|
const visualization = shallowRef<Visualization>()
|
||||||
|
|
||||||
|
onErrorCaptured((error) => {
|
||||||
|
vueError.value = error
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeVisualizationData = projectStore.useVisualizationData(() => {
|
||||||
|
const dataSourceValue = toValue(dataSource)
|
||||||
|
if (dataSourceValue?.type !== 'node') return
|
||||||
|
return {
|
||||||
|
...visPreprocessor.value,
|
||||||
|
expressionId: dataSourceValue.nodeId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const expressionVisualizationData = computedAsync(() => {
|
||||||
|
const dataSourceValue = toValue(dataSource)
|
||||||
|
if (dataSourceValue?.type !== 'expression') return
|
||||||
|
if (preprocessorLoading.value) return
|
||||||
|
const preprocessor = visPreprocessor.value
|
||||||
|
const args = preprocessor.positionalArgumentsExpressions
|
||||||
|
const tempModule = Ast.MutableModule.Transient()
|
||||||
|
const preprocessorModule = Ast.parse(preprocessor.visualizationModule, tempModule)
|
||||||
|
// TODO[ao]: it work with builtin visualization, but does not work in general case.
|
||||||
|
// Tracked in https://github.com/orgs/enso-org/discussions/6832#discussioncomment-7754474.
|
||||||
|
if (!isIdentifier(preprocessor.expression)) {
|
||||||
|
console.error(`Unsupported visualization preprocessor definition`, preprocessor)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const preprocessorQn = Ast.PropertyAccess.new(
|
||||||
|
tempModule,
|
||||||
|
preprocessorModule,
|
||||||
|
preprocessor.expression,
|
||||||
|
)
|
||||||
|
const preprocessorInvocation = Ast.App.PositionalSequence(preprocessorQn, [
|
||||||
|
Ast.Wildcard.new(tempModule),
|
||||||
|
...args.map((arg) => Ast.Group.new(tempModule, Ast.parse(arg, tempModule))),
|
||||||
|
])
|
||||||
|
const rhs = Ast.parse(dataSourceValue.expression, tempModule)
|
||||||
|
const expression = Ast.OprApp.new(tempModule, preprocessorInvocation, '<|', rhs)
|
||||||
|
return projectStore.executeExpression(dataSourceValue.contextId, expression.code())
|
||||||
|
})
|
||||||
|
|
||||||
|
const effectiveVisualizationData = computed(() => {
|
||||||
|
const dataSourceValue = toValue(dataSource)
|
||||||
|
const name = currentType.value?.name
|
||||||
|
if (dataSourceValue?.type === 'raw') return dataSourceValue.data
|
||||||
|
if (vueError.value) return { name, error: vueError.value }
|
||||||
|
const visualizationData = nodeVisualizationData.value ?? expressionVisualizationData.value
|
||||||
|
if (!visualizationData) return
|
||||||
|
if (visualizationData.ok) return visualizationData.value
|
||||||
|
else return { name, error: new Error(`${visualizationData.error.payload}`) }
|
||||||
|
})
|
||||||
|
|
||||||
|
function updatePreprocessor(
|
||||||
|
visualizationModule: string,
|
||||||
|
expression: string,
|
||||||
|
...positionalArgumentsExpressions: string[]
|
||||||
|
) {
|
||||||
|
visPreprocessor.value = { visualizationModule, expression, positionalArgumentsExpressions }
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToDefaultPreprocessor() {
|
||||||
|
visPreprocessor.value = DEFAULT_VISUALIZATION_CONFIGURATION
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [currentType.value, visualization.value],
|
||||||
|
() => (vueError.value = undefined),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flag used to prevent rendering the visualization with a stale preprocessor while the new preprocessor is being
|
||||||
|
// prepared asynchronously.
|
||||||
|
const preprocessorLoading = ref(false)
|
||||||
|
watchEffect(async () => {
|
||||||
|
preprocessorLoading.value = true
|
||||||
|
if (currentType.value == null) return
|
||||||
|
visualization.value = undefined
|
||||||
|
try {
|
||||||
|
const module = await visualizationStore.get(currentType.value).value
|
||||||
|
if (module) {
|
||||||
|
if (module.defaultPreprocessor != null) {
|
||||||
|
updatePreprocessor(...module.defaultPreprocessor)
|
||||||
|
} else {
|
||||||
|
switchToDefaultPreprocessor()
|
||||||
|
}
|
||||||
|
visualization.value = module.default
|
||||||
|
} else {
|
||||||
|
switch (currentType.value.module.kind) {
|
||||||
|
case 'Builtin': {
|
||||||
|
vueError.value = new Error(
|
||||||
|
`The builtin visualization '${currentType.value.name}' was not found.`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'CurrentProject': {
|
||||||
|
vueError.value = new Error(
|
||||||
|
`The visualization '${currentType.value.name}' was not found in the current project.`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'Library': {
|
||||||
|
vueError.value = new Error(
|
||||||
|
`The visualization '${currentType.value.name}' was not found in the library '${currentType.value.module.name}'.`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (caughtError) {
|
||||||
|
vueError.value = toError(caughtError)
|
||||||
|
}
|
||||||
|
preprocessorLoading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const allTypes = computed(() => Array.from(visualizationStore.types(toValue(typename))))
|
||||||
|
|
||||||
|
const effectiveVisualization = computed(() => {
|
||||||
|
if (
|
||||||
|
vueError.value ||
|
||||||
|
(nodeVisualizationData.value && !nodeVisualizationData.value.ok) ||
|
||||||
|
(expressionVisualizationData.value && !expressionVisualizationData.value.ok)
|
||||||
|
) {
|
||||||
|
return LoadingErrorVisualization
|
||||||
|
}
|
||||||
|
if (!visualization.value || effectiveVisualizationData.value == null) {
|
||||||
|
return LoadingVisualization
|
||||||
|
}
|
||||||
|
return visualization.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Visualization-provided configuration
|
||||||
|
const toolbarOverlay = ref(false)
|
||||||
|
const toolbarDefinition = shallowRef<ToValue<Readonly<ToolbarItem[]>>>()
|
||||||
|
watch(effectiveVisualization, () => {
|
||||||
|
toolbarOverlay.value = false
|
||||||
|
toolbarDefinition.value = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
effectiveVisualization,
|
||||||
|
effectiveVisualizationData,
|
||||||
|
updatePreprocessor,
|
||||||
|
allTypes,
|
||||||
|
currentType,
|
||||||
|
setToolbarDefinition: (definition: ToValue<Readonly<ToolbarItem[]>>) =>
|
||||||
|
(toolbarDefinition.value = definition),
|
||||||
|
visualizationDefinedToolbar: computed(() => toValue(toolbarDefinition.value)),
|
||||||
|
toolbarOverlay,
|
||||||
|
}
|
||||||
|
}
|
@ -284,7 +284,7 @@ const isMulti = computed(() => props.input.dynamicConfig?.kind === 'Multiple_Cho
|
|||||||
const dropDownInteraction = WidgetEditHandler.New('WidgetSelection', props.input, {
|
const dropDownInteraction = WidgetEditHandler.New('WidgetSelection', props.input, {
|
||||||
cancel: onClose,
|
cancel: onClose,
|
||||||
end: onClose,
|
end: onClose,
|
||||||
pointerdown: (e, _) => {
|
pointerdown: (e) => {
|
||||||
if (
|
if (
|
||||||
targetIsOutside(e, unrefElement(dropdownElement)) &&
|
targetIsOutside(e, unrefElement(dropdownElement)) &&
|
||||||
targetIsOutside(e, unrefElement(activityElement)) &&
|
targetIsOutside(e, unrefElement(activityElement)) &&
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
type RowData,
|
type RowData,
|
||||||
} from '@/components/GraphEditor/widgets/WidgetTableEditor/tableNewArgument'
|
} from '@/components/GraphEditor/widgets/WidgetTableEditor/tableNewArgument'
|
||||||
import ResizeHandles from '@/components/ResizeHandles.vue'
|
import ResizeHandles from '@/components/ResizeHandles.vue'
|
||||||
import AgGridTableView from '@/components/widgets/AgGridTableView.vue'
|
import AgGridTableView from '@/components/shared/AgGridTableView.vue'
|
||||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||||
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||||
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
|
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { blockTypeToBlockName, type BlockType } from '@/components/MarkdownEditor/formatting'
|
import { blockTypeToBlockName, type BlockType } from '@/components/MarkdownEditor/formatting'
|
||||||
import SelectionDropdown from '@/components/SelectionDropdown.vue'
|
import SelectionDropdown from '@/components/SelectionDropdown.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
|
||||||
import type { Icon } from '@/util/iconName'
|
import type { Icon } from '@/util/iconName'
|
||||||
|
|
||||||
const blockType = defineModel<BlockType>({ required: true })
|
const blockType = defineModel<BlockType>({ required: true })
|
||||||
@ -26,13 +25,15 @@ const blockTypesOrdered: BlockType[] = [
|
|||||||
'number',
|
'number',
|
||||||
'quote',
|
'quote',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const blockTypeOptions = Object.fromEntries(
|
||||||
|
blockTypesOrdered.map((key) => [
|
||||||
|
key,
|
||||||
|
{ icon: blockTypeIcon[key], label: blockTypeToBlockName[key] },
|
||||||
|
]),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectionDropdown v-model="blockType" :values="blockTypesOrdered">
|
<SelectionDropdown v-model="blockType" :options="blockTypeOptions" labelButton />
|
||||||
<template #default="{ value }">
|
|
||||||
<SvgIcon :name="blockTypeIcon[value]" />
|
|
||||||
<div class="iconLabel" v-text="blockTypeToBlockName[value]" />
|
|
||||||
</template>
|
|
||||||
</SelectionDropdown>
|
|
||||||
</template>
|
</template>
|
||||||
|
@ -1,28 +1,47 @@
|
|||||||
<script setup lang="ts" generic="T extends string | number | symbol">
|
<script setup lang="ts">
|
||||||
|
/** @file A dropdown menu supporting the pattern of selecting a single entry from a list. */
|
||||||
|
|
||||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||||
import MenuButton from '@/components/MenuButton.vue'
|
import MenuButton from '@/components/MenuButton.vue'
|
||||||
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
|
import { SelectionMenuOption } from '@/components/visualizations/toolbar'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const selected = defineModel<T>({ required: true })
|
type Key = number | string | symbol
|
||||||
const _props = defineProps<{ values: T[] }>()
|
const selected = defineModel<Key>({ required: true })
|
||||||
|
const _props = defineProps<{
|
||||||
|
options: Record<Key, SelectionMenuOption>
|
||||||
|
title?: string | undefined
|
||||||
|
labelButton?: boolean
|
||||||
|
alwaysShowArrow?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenu v-model:open="open">
|
<DropdownMenu v-model:open="open" :title="title" :alwaysShowArrow="alwaysShowArrow">
|
||||||
<template #button>
|
<template #button>
|
||||||
<slot :value="selected" />
|
<template v-if="options[selected]">
|
||||||
|
<SvgIcon :name="options[selected]!.icon" :style="options[selected]!.iconStyle" />
|
||||||
|
<div
|
||||||
|
v-if="labelButton && options[selected]!.label"
|
||||||
|
class="iconLabel"
|
||||||
|
v-text="options[selected]!.label"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #entries>
|
<template #entries>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
v-for="value in values"
|
v-for="[key, option] in Object.entries(options)"
|
||||||
:key="value"
|
:key="key"
|
||||||
:modelValue="selected === value"
|
:title="option.title"
|
||||||
@update:modelValue="$event && (selected = value)"
|
:modelValue="selected === key"
|
||||||
|
@update:modelValue="$event && (selected = key)"
|
||||||
@click="open = false"
|
@click="open = false"
|
||||||
>
|
>
|
||||||
<slot :value="value" />
|
<SvgIcon :name="option.icon" :style="option.iconStyle" :data-testid="option.dataTestid" />
|
||||||
|
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@ -31,5 +50,11 @@ const open = ref(false)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.MenuButton {
|
.MenuButton {
|
||||||
margin: -4px;
|
margin: -4px;
|
||||||
|
justify-content: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconLabel {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -6,9 +6,9 @@ import type { Icon } from '@/util/iconName'
|
|||||||
|
|
||||||
const _props = defineProps<{
|
const _props = defineProps<{
|
||||||
name: Icon | URLString
|
name: Icon | URLString
|
||||||
label?: string
|
label?: string | undefined
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
title?: string
|
title?: string | undefined
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -1,279 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
|
||||||
import MenuButton from '@/components/MenuButton.vue'
|
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
|
||||||
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
|
||||||
import { Ast } from '@/util/ast'
|
|
||||||
import { Pattern } from '@/util/ast/match'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import type { NodeCreationOptions } from './GraphEditor/nodeCreation'
|
|
||||||
import { TextFormatOptions } from './visualizations/TableVisualization.vue'
|
|
||||||
|
|
||||||
type SortDirection = 'asc' | 'desc'
|
|
||||||
export type SortModel = {
|
|
||||||
columnName: string
|
|
||||||
sortDirection: SortDirection
|
|
||||||
sortIndex: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
filterModel: {
|
|
||||||
[key: string]: {
|
|
||||||
values: any[]
|
|
||||||
filterType: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sortModel: SortModel[]
|
|
||||||
isDisabled: boolean
|
|
||||||
isFilterSortNodeEnabled: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
changeFormat: [formatValue: TextFormatOptions]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const textFormatterSelected = ref(TextFormatOptions.Partial)
|
|
||||||
watch(textFormatterSelected, (selected) => emit('changeFormat', selected))
|
|
||||||
|
|
||||||
const config = useVisualizationConfig()
|
|
||||||
|
|
||||||
const sortPatternPattern = computed(() => Pattern.parse('(..Name __ __ )'))
|
|
||||||
|
|
||||||
const sortDirection = computed(() => ({
|
|
||||||
asc: '..Ascending',
|
|
||||||
desc: '..Descending',
|
|
||||||
}))
|
|
||||||
|
|
||||||
const makeSortPattern = (module: Ast.MutableModule) => {
|
|
||||||
const columnSortExpressions = props.sortModel
|
|
||||||
.filter((sort) => sort?.columnName)
|
|
||||||
.sort((a, b) => a.sortIndex - b.sortIndex)
|
|
||||||
.map((sort) =>
|
|
||||||
sortPatternPattern.value.instantiateCopied([
|
|
||||||
Ast.TextLiteral.new(sort.columnName),
|
|
||||||
Ast.parse(sortDirection.value[sort.sortDirection as SortDirection]),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
return Ast.Vector.new(module, columnSortExpressions)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterPattern = computed(() => Pattern.parse('__ (__ __)'))
|
|
||||||
|
|
||||||
const makeFilterPattern = (module: Ast.MutableModule, columnName: string, items: string[]) => {
|
|
||||||
if (
|
|
||||||
(items?.length === 1 && items.indexOf('true') != -1) ||
|
|
||||||
(items?.length === 1 && items.indexOf('false') != -1)
|
|
||||||
) {
|
|
||||||
const boolToInclude = items.indexOf('false') != -1 ? Ast.parse('False') : Ast.parse('True')
|
|
||||||
return filterPattern.value.instantiateCopied([
|
|
||||||
Ast.TextLiteral.new(columnName),
|
|
||||||
Ast.parse('..Equal'),
|
|
||||||
boolToInclude,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
const itemList = items.map((i) => Ast.TextLiteral.new(i))
|
|
||||||
return filterPattern.value.instantiateCopied([
|
|
||||||
Ast.TextLiteral.new(columnName),
|
|
||||||
Ast.parse('..Is_In'),
|
|
||||||
Ast.Vector.new(module, itemList),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAstPatternSort() {
|
|
||||||
return Pattern.new((ast) =>
|
|
||||||
Ast.App.positional(
|
|
||||||
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('sort')!),
|
|
||||||
makeSortPattern(ast.module),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAstPatternFilter(columnName: string, items: string[]) {
|
|
||||||
return Pattern.new((ast) =>
|
|
||||||
Ast.App.positional(
|
|
||||||
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('filter')!),
|
|
||||||
makeFilterPattern(ast.module, columnName, items),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAstPatternFilterAndSort(columnName: string, items: string[]) {
|
|
||||||
return Pattern.new((ast) =>
|
|
||||||
Ast.OprApp.new(
|
|
||||||
ast.module,
|
|
||||||
Ast.App.positional(
|
|
||||||
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('filter')!),
|
|
||||||
makeFilterPattern(ast.module, columnName, items),
|
|
||||||
),
|
|
||||||
'.',
|
|
||||||
Ast.App.positional(
|
|
||||||
Ast.Ident.new(ast.module, Ast.identifier('sort')!),
|
|
||||||
makeSortPattern(ast.module),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const createNewNodes = () => {
|
|
||||||
let patterns = new Array<any>()
|
|
||||||
if (Object.keys(props.filterModel).length && props.sortModel.length) {
|
|
||||||
for (const index in Object.keys(props.filterModel)) {
|
|
||||||
const columnName = Object.keys(props.filterModel)[index]!
|
|
||||||
const items = props.filterModel[columnName || '']?.values.map((item) => `${item}`)!
|
|
||||||
const filterPatterns = getAstPatternFilterAndSort(columnName, items)
|
|
||||||
patterns.push(filterPatterns)
|
|
||||||
}
|
|
||||||
} else if (Object.keys(props.filterModel).length) {
|
|
||||||
for (const index in Object.keys(props.filterModel)) {
|
|
||||||
const columnName = Object.keys(props.filterModel)[index]!
|
|
||||||
const items = props.filterModel[columnName || '']?.values.map((item) => `${item}`)!
|
|
||||||
const filterPatterns = getAstPatternFilter(columnName, items)
|
|
||||||
patterns.push(filterPatterns)
|
|
||||||
}
|
|
||||||
} else if (props.sortModel.length) {
|
|
||||||
const patSort = getAstPatternSort()
|
|
||||||
patterns.push(patSort)
|
|
||||||
}
|
|
||||||
config.createNodes(
|
|
||||||
...patterns.map(
|
|
||||||
(pattern) => ({ content: pattern, commit: true }) satisfies NodeCreationOptions,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonClass = computed(() => {
|
|
||||||
return {
|
|
||||||
full: isFormatOptionSelected(TextFormatOptions.On),
|
|
||||||
partial: isFormatOptionSelected(TextFormatOptions.Partial),
|
|
||||||
strikethrough: isFormatOptionSelected(TextFormatOptions.Off),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isFormatOptionSelected = (option: TextFormatOptions): boolean =>
|
|
||||||
option === textFormatterSelected.value
|
|
||||||
|
|
||||||
const open = ref(false)
|
|
||||||
const toggleOpen = () => {
|
|
||||||
open.value = !open.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeFormat = (option: TextFormatOptions) => {
|
|
||||||
textFormatterSelected.value = option
|
|
||||||
toggleOpen()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="TableVizToolbar">
|
|
||||||
<DropdownMenu v-model:open="open" class="TextFormattingSelector" title="Text Display Options">
|
|
||||||
<template #button
|
|
||||||
><div :class="buttonClass">
|
|
||||||
<SvgIcon name="paragraph" /></div
|
|
||||||
></template>
|
|
||||||
|
|
||||||
<template #entries>
|
|
||||||
<MenuButton
|
|
||||||
class="full"
|
|
||||||
title="Text displayed in monospace font and all whitespace characters displayed as symbols"
|
|
||||||
@click="() => changeFormat(TextFormatOptions.On)"
|
|
||||||
>
|
|
||||||
<SvgIcon name="paragraph" />
|
|
||||||
<div class="title">Full whitespace rendering</div>
|
|
||||||
</MenuButton>
|
|
||||||
|
|
||||||
<MenuButton
|
|
||||||
class="partial"
|
|
||||||
title="Text displayed in monospace font, only multiple spaces displayed with ·"
|
|
||||||
@click="() => changeFormat(TextFormatOptions.Partial)"
|
|
||||||
>
|
|
||||||
<SvgIcon name="paragraph" />
|
|
||||||
<div class="title">Partial whitespace rendering</div>
|
|
||||||
</MenuButton>
|
|
||||||
|
|
||||||
<MenuButton
|
|
||||||
class="off"
|
|
||||||
title="No formatting applied to text"
|
|
||||||
@click="() => changeFormat(TextFormatOptions.Off)"
|
|
||||||
>
|
|
||||||
<div class="strikethrough">
|
|
||||||
<SvgIcon name="paragraph" />
|
|
||||||
</div>
|
|
||||||
<div class="title">No whitespace rendering</div>
|
|
||||||
</MenuButton>
|
|
||||||
</template>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isFilterSortNodeEnabled" class="sortFilterNode">
|
|
||||||
<SvgButton
|
|
||||||
name="add"
|
|
||||||
title="Create new component(s) with the current grid's sort and filters applied to the workflow"
|
|
||||||
:disabled="props.isDisabled"
|
|
||||||
@click="createNewNodes()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.TableVizToolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
background: var(--color-frame-bg);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.DropdownMenuContent) {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 4px;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
display: flex;
|
|
||||||
padding-left: 8px;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.strikethrough {
|
|
||||||
position: relative;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
.strikethrough:before {
|
|
||||||
position: absolute;
|
|
||||||
content: '';
|
|
||||||
left: 0;
|
|
||||||
top: 50%;
|
|
||||||
right: 0;
|
|
||||||
border-top: 1px solid;
|
|
||||||
border-color: black;
|
|
||||||
|
|
||||||
-webkit-transform: rotate(-20deg);
|
|
||||||
-moz-transform: rotate(-20deg);
|
|
||||||
-ms-transform: rotate(-20deg);
|
|
||||||
-o-transform: rotate(-20deg);
|
|
||||||
transform: rotate(-20deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.partial {
|
|
||||||
stroke: grey;
|
|
||||||
fill: #808080;
|
|
||||||
}
|
|
||||||
|
|
||||||
.off {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full {
|
|
||||||
stroke: black;
|
|
||||||
fill: #000000;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
padding-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sortFilterNode {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -8,16 +8,22 @@
|
|||||||
|
|
||||||
import MenuButton from '@/components/MenuButton.vue'
|
import MenuButton from '@/components/MenuButton.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
|
import { URLString } from '@/util/data/urlString'
|
||||||
import type { Icon } from '@/util/iconName'
|
import type { Icon } from '@/util/iconName'
|
||||||
|
|
||||||
const toggledOn = defineModel<boolean>({ default: false })
|
const toggledOn = defineModel<boolean>({ default: false })
|
||||||
const props = defineProps<{ icon: Icon; label?: string; disabled?: boolean }>()
|
const _props = defineProps<{
|
||||||
|
icon: Icon | URLString
|
||||||
|
title?: string | undefined
|
||||||
|
label?: string | undefined
|
||||||
|
disabled?: boolean | undefined
|
||||||
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MenuButton v-model="toggledOn" class="ToggleIcon" :disabled="props.disabled">
|
<MenuButton v-model="toggledOn" class="ToggleIcon" :disabled="disabled" :title="title">
|
||||||
<SvgIcon :name="icon" />
|
<SvgIcon :name="icon" />
|
||||||
<div v-if="props.label" v-text="props.label" />
|
<div v-if="label" v-text="label" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -1,108 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/** @file The layout of a visualization within a node. */
|
|
||||||
|
|
||||||
import ResizeHandles from '@/components/ResizeHandles.vue'
|
|
||||||
import VisualizationPanel from '@/components/VisualizationPanel.vue'
|
|
||||||
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
|
||||||
import { Rect, type BoundsSet } from '@/util/data/rect'
|
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
|
||||||
import { computed, watch, watchEffect } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
/** If true, the visualization should be `overflow: visible` instead of `overflow: auto`. */
|
|
||||||
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
|
|
||||||
toolbarOverflow?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const config = useVisualizationConfig()
|
|
||||||
|
|
||||||
watchEffect(() => (config.isBelowToolbar = props.belowToolbar))
|
|
||||||
|
|
||||||
const contentSize = computed(() => new Vec2(config.width, config.height))
|
|
||||||
|
|
||||||
// Because ResizeHandles are applying the screen mouse movements, the bounds must be in `screen`
|
|
||||||
// space.
|
|
||||||
const clientBounds = computed({
|
|
||||||
get() {
|
|
||||||
return new Rect(Vec2.Zero, contentSize.value.scale(config.scale))
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
if (resizing.left || resizing.right) config.width = value.width / config.scale
|
|
||||||
if (resizing.bottom) config.height = value.height / config.scale
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
let resizing: BoundsSet = {}
|
|
||||||
|
|
||||||
watch(contentSize, (newVal, oldVal) => {
|
|
||||||
if (!resizing.left) return
|
|
||||||
const delta = newVal.x - oldVal.x
|
|
||||||
if (delta !== 0)
|
|
||||||
config.nodePosition = new Vec2(config.nodePosition.x - delta, config.nodePosition.y)
|
|
||||||
})
|
|
||||||
|
|
||||||
const style = computed(() => {
|
|
||||||
return {
|
|
||||||
'--color-visualization-bg': config.background,
|
|
||||||
'--node-size-x': `${config.nodeSize.x}px`,
|
|
||||||
'--node-size-y': `${config.nodeSize.y}px`,
|
|
||||||
width: `${config.width}px`,
|
|
||||||
'--content-height': `${config.height}px`,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="VisualizationContainer"
|
|
||||||
:class="{
|
|
||||||
'below-node': props.belowNode,
|
|
||||||
'below-toolbar': props.belowToolbar,
|
|
||||||
}"
|
|
||||||
:style="style"
|
|
||||||
>
|
|
||||||
<VisualizationPanel :overflow="overflow ?? false" :toolbarOverflow="toolbarOverflow ?? false">
|
|
||||||
<template v-if="$slots.toolbar" #toolbar><slot name="toolbar" /></template>
|
|
||||||
<template #default><slot /></template>
|
|
||||||
</VisualizationPanel>
|
|
||||||
<ResizeHandles
|
|
||||||
v-if="!config.isPreview && config.isResizable"
|
|
||||||
v-model="clientBounds"
|
|
||||||
left
|
|
||||||
right
|
|
||||||
bottom
|
|
||||||
@update:resizing="resizing = $event"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.VisualizationContainer {
|
|
||||||
--resize-handle-inside: var(--visualization-resize-handle-inside);
|
|
||||||
--resize-handle-outside: var(--visualization-resize-handle-outside);
|
|
||||||
--resize-handle-radius: var(--radius-default);
|
|
||||||
--toolbar-reserved-height: 36px;
|
|
||||||
position: absolute;
|
|
||||||
border-radius: var(--radius-default);
|
|
||||||
background: var(--color-visualization-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.VisualizationContainer {
|
|
||||||
padding-top: var(--node-size-y);
|
|
||||||
height: calc(var(--content-height) + var(--node-size-y));
|
|
||||||
}
|
|
||||||
|
|
||||||
.VisualizationContainer.below-node {
|
|
||||||
padding-top: var(--node-size-y);
|
|
||||||
height: calc(var(--content-height) + var(--node-size-y));
|
|
||||||
}
|
|
||||||
|
|
||||||
.VisualizationContainer.below-toolbar {
|
|
||||||
padding-top: var(--node-size-y);
|
|
||||||
height: calc(var(--content-height) + var(--node-size-y) + var(--toolbar-reserved-height));
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,210 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/** @file
|
|
||||||
* Contains a visualization's toolbars and the visualization itself. The panel's size is determined by its enclosing
|
|
||||||
* container (in fullscreen mode, this will be the fullscreen-container element).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import FullscreenButton from '@/components/FullscreenButton.vue'
|
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
|
||||||
import VisualizationSelector from '@/components/VisualizationSelector.vue'
|
|
||||||
import WithFullscreenMode from '@/components/WithFullscreenMode.vue'
|
|
||||||
import { isTriggeredByKeyboard } from '@/composables/events'
|
|
||||||
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
|
||||||
import { isQualifiedName, qnLastSegment } from '@/util/qualifiedName'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
overflow?: boolean
|
|
||||||
toolbarOverflow?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const config = useVisualizationConfig()
|
|
||||||
|
|
||||||
function onWheel(event: WheelEvent) {
|
|
||||||
if (
|
|
||||||
event.currentTarget instanceof Element &&
|
|
||||||
(config.fullscreen ||
|
|
||||||
event.currentTarget.scrollWidth > event.currentTarget.clientWidth ||
|
|
||||||
event.currentTarget.scrollHeight > event.currentTarget.clientHeight)
|
|
||||||
) {
|
|
||||||
event.stopPropagation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const UNKNOWN_TYPE = 'Unknown'
|
|
||||||
const nodeShortType = computed(() =>
|
|
||||||
config.nodeType != null && isQualifiedName(config.nodeType) ?
|
|
||||||
qnLastSegment(config.nodeType)
|
|
||||||
: UNKNOWN_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
const fullscreenAnimating = ref(false)
|
|
||||||
|
|
||||||
const isSelectorVisible = ref(false)
|
|
||||||
|
|
||||||
function hideSelector() {
|
|
||||||
requestAnimationFrame(() => (isSelectorVisible.value = false))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<WithFullscreenMode
|
|
||||||
v-model:savedSize="config.savedSize"
|
|
||||||
:fullscreen="config.fullscreen"
|
|
||||||
@update:animating="fullscreenAnimating = $event"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="VisualizationPanel"
|
|
||||||
:class="{
|
|
||||||
fullscreen: config.fullscreen || fullscreenAnimating,
|
|
||||||
nonInteractive: config.isPreview,
|
|
||||||
}"
|
|
||||||
:style="{
|
|
||||||
'--color-visualization-bg': config.background,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="toolbars">
|
|
||||||
<div
|
|
||||||
v-if="!config.isPreview && !config.fullscreen"
|
|
||||||
class="toolbar"
|
|
||||||
:class="{ invisible: config.isCircularMenuVisible }"
|
|
||||||
>
|
|
||||||
<SvgButton name="eye" title="Hide visualization" @click.stop="config.hide()" />
|
|
||||||
</div>
|
|
||||||
<div v-if="!config.isPreview" class="toolbar">
|
|
||||||
<FullscreenButton v-if="config.isFullscreenAllowed" v-model="config.fullscreen" />
|
|
||||||
<div class="icon-container">
|
|
||||||
<SvgButton
|
|
||||||
:name="config.icon ?? 'columns_increasing'"
|
|
||||||
title="Visualization Selector"
|
|
||||||
@click.stop.prevent="
|
|
||||||
(!isSelectorVisible || isTriggeredByKeyboard($event)) &&
|
|
||||||
(isSelectorVisible = !isSelectorVisible)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<Suspense>
|
|
||||||
<VisualizationSelector
|
|
||||||
v-if="isSelectorVisible"
|
|
||||||
:types="config.types"
|
|
||||||
:modelValue="config.currentType"
|
|
||||||
@hide="hideSelector"
|
|
||||||
@update:modelValue="(isSelectorVisible = false), config.updateType($event)"
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="$slots.toolbar && !config.isPreview"
|
|
||||||
class="visualization-defined-toolbars"
|
|
||||||
:class="{ overflow: props.toolbarOverflow }"
|
|
||||||
>
|
|
||||||
<div class="toolbar"><slot name="toolbar"></slot></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="after-toolbars node-type"
|
|
||||||
:title="config.nodeType ?? UNKNOWN_TYPE"
|
|
||||||
v-text="nodeShortType"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="content scrollable"
|
|
||||||
:class="{ overflow: props.overflow }"
|
|
||||||
@wheel.passive="onWheel"
|
|
||||||
>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</WithFullscreenMode>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.VisualizationPanel {
|
|
||||||
--permanent-toolbar-width: 240px;
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: default;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
&.fullscreen {
|
|
||||||
background: var(--color-visualization-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbars {
|
|
||||||
flex: 0;
|
|
||||||
transition-duration: 100ms;
|
|
||||||
transition-property: padding-left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
overflow: auto;
|
|
||||||
contain: strict;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbars {
|
|
||||||
width: 100%;
|
|
||||||
user-select: none;
|
|
||||||
margin-top: 4px;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.after-toolbars {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
width: calc(var(--node-size-x) - var(--permanent-toolbar-width));
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-type {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
gap: 12px;
|
|
||||||
padding: 8px;
|
|
||||||
z-index: 20;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: -1;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background: var(--color-app-bg);
|
|
||||||
backdrop-filter: var(--blur-app-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar:not(:first-child):not(:has(> *)) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visualization-defined-toolbars {
|
|
||||||
max-width: calc(100% - var(--permanent-toolbar-width));
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar > :deep(*) {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nonInteractive {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,22 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SelectionDropdown from '@/components/SelectionDropdown.vue'
|
||||||
import { useVisualizationStore } from '@/stores/visualization'
|
import { useVisualizationStore } from '@/stores/visualization'
|
||||||
import { useAutoBlur } from '@/util/autoBlur'
|
import { computed } from 'vue'
|
||||||
import { onMounted, ref } from 'vue'
|
import { type VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||||
import { visIdentifierEquals, type VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
|
||||||
|
|
||||||
|
const modelValue = defineModel<VisualizationIdentifier>({ required: true })
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
types: Iterable<VisualizationIdentifier>
|
types: Iterable<VisualizationIdentifier>
|
||||||
modelValue: VisualizationIdentifier
|
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ hide: []; 'update:modelValue': [type: VisualizationIdentifier] }>()
|
|
||||||
|
|
||||||
const visualizationStore = useVisualizationStore()
|
const visualizationStore = useVisualizationStore()
|
||||||
|
|
||||||
const rootNode = ref<HTMLElement>()
|
function visLabel(id: VisualizationIdentifier) {
|
||||||
useAutoBlur(rootNode)
|
|
||||||
|
|
||||||
function visIdLabel(id: VisualizationIdentifier) {
|
|
||||||
switch (id.module.kind) {
|
switch (id.module.kind) {
|
||||||
case 'Builtin':
|
case 'Builtin':
|
||||||
return id.name
|
return id.name
|
||||||
@ -27,91 +22,37 @@ function visIdLabel(id: VisualizationIdentifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function visIdKey(id: VisualizationIdentifier) {
|
function visKey(id: VisualizationIdentifier) {
|
||||||
const kindKey = id.module.kind === 'Library' ? `Library::${id.module.name}` : id.module.kind
|
const kindKey = id.module.kind === 'Library' ? `Library::${id.module.name}` : id.module.kind
|
||||||
return `${kindKey}::${id.name}`
|
return `${kindKey}::${id.name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => setTimeout(() => rootNode.value?.querySelector('button')?.focus(), 1))
|
const visualizationByKey = computed(() => {
|
||||||
|
const visualizations = new Map<string, VisualizationIdentifier>()
|
||||||
|
for (const type_ of props.types) visualizations.set(visKey(type_), type_)
|
||||||
|
return visualizations
|
||||||
|
})
|
||||||
|
|
||||||
|
const visualizationOptions = computed(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Array.from(props.types, (vis) => [
|
||||||
|
visKey(vis),
|
||||||
|
{
|
||||||
|
icon: visualizationStore.icon(vis) ?? 'columns_increasing',
|
||||||
|
label: visLabel(vis),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<SelectionDropdown
|
||||||
ref="rootNode"
|
:modelValue="visKey(modelValue)"
|
||||||
|
:options="visualizationOptions"
|
||||||
|
title="Visualization Selector"
|
||||||
class="VisualizationSelector"
|
class="VisualizationSelector"
|
||||||
@focusout="$event.relatedTarget == null && emit('hide')"
|
alwaysShowArrow
|
||||||
>
|
@update:modelValue="modelValue = visualizationByKey.get($event as string)!"
|
||||||
<div class="background"></div>
|
/>
|
||||||
<ul>
|
|
||||||
<li
|
|
||||||
v-for="type_ in props.types"
|
|
||||||
:key="visIdKey(type_)"
|
|
||||||
:class="{ selected: visIdentifierEquals(props.modelValue, type_) }"
|
|
||||||
class="clickable"
|
|
||||||
@click.stop="emit('update:modelValue', type_)"
|
|
||||||
>
|
|
||||||
<button>
|
|
||||||
<SvgIcon class="icon" :name="visualizationStore.icon(type_) ?? 'columns_increasing'" />
|
|
||||||
<span v-text="visIdLabel(type_)"></span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
</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;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: var(--color-app-bg);
|
|
||||||
backdrop-filter: var(--blur-app-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.VisualizationSelector > * {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
display: flex;
|
|
||||||
flex-flow: column;
|
|
||||||
gap: 2px;
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background: var(--color-menu-entry-selected-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-menu-entry-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: var(--color-menu-entry-active-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -22,8 +22,8 @@ const props = defineProps<{
|
|||||||
* - If the new component leaves fullscreen mode, its "return" to the dimensions of the original component will be
|
* - If the new component leaves fullscreen mode, its "return" to the dimensions of the original component will be
|
||||||
* animated.
|
* animated.
|
||||||
*
|
*
|
||||||
* This approach is used when switching visualizations (which replaces the `VisualizationContainer` and its
|
* This approach was previously used when switching visualizations (when each visualization had its own
|
||||||
* `WithFullscreenMode` instance).
|
* `VisualizationContainer` and `WithFullscreenMode` instance).
|
||||||
*/
|
*/
|
||||||
const savedSize = defineModel<SavedSize | undefined>('savedSize')
|
const savedSize = defineModel<SavedSize | undefined>('savedSize')
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -8,9 +8,8 @@ import {
|
|||||||
tsvTableToEnsoExpression,
|
tsvTableToEnsoExpression,
|
||||||
writeClipboard,
|
writeClipboard,
|
||||||
} from '@/components/GraphEditor/clipboard'
|
} from '@/components/GraphEditor/clipboard'
|
||||||
|
import { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue'
|
||||||
import { useAutoBlur } from '@/util/autoBlur'
|
import { useAutoBlur } from '@/util/autoBlur'
|
||||||
import '@ag-grid-community/styles/ag-grid.css'
|
|
||||||
import '@ag-grid-community/styles/ag-theme-alpine.css'
|
|
||||||
import type {
|
import type {
|
||||||
CellEditingStartedEvent,
|
CellEditingStartedEvent,
|
||||||
CellEditingStoppedEvent,
|
CellEditingStoppedEvent,
|
||||||
@ -28,7 +27,6 @@ import type {
|
|||||||
SortChangedEvent,
|
SortChangedEvent,
|
||||||
} from 'ag-grid-enterprise'
|
} from 'ag-grid-enterprise'
|
||||||
import { type ComponentInstance, reactive, ref, shallowRef, watch } from 'vue'
|
import { type ComponentInstance, reactive, ref, shallowRef, watch } from 'vue'
|
||||||
import { TextFormatOptions } from '../visualizations/TableVisualization.vue'
|
|
||||||
|
|
||||||
const DEFAULT_ROW_HEIGHT = 22
|
const DEFAULT_ROW_HEIGHT = 22
|
||||||
|
|
||||||
@ -204,6 +202,8 @@ const { AgGridVue } = await import('ag-grid-vue3')
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style src="@ag-grid-community/styles/ag-grid.css" />
|
||||||
|
<style src="@ag-grid-community/styles/ag-theme-alpine.css" />
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ag-theme-alpine {
|
.ag-theme-alpine {
|
||||||
--ag-grid-size: 3px;
|
--ag-grid-size: 3px;
|
@ -88,8 +88,7 @@ declare var deck: typeof import('deck.gl')
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/// <reference types="@danmarshall/deckgl-typings" />
|
/// <reference types="@danmarshall/deckgl-typings" />
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||||
import { VisualizationContainer } from '@/util/visualizationBuiltins'
|
|
||||||
import type { Deck } from 'deck.gl'
|
import type { Deck } from 'deck.gl'
|
||||||
import { computed, onUnmounted, ref, watchPostEffect } from 'vue'
|
import { computed, onUnmounted, ref, watchPostEffect } from 'vue'
|
||||||
|
|
||||||
@ -122,6 +121,8 @@ const DEFAULT_MAP_ZOOM = 11
|
|||||||
const DEFAULT_MAX_MAP_ZOOM = 18
|
const DEFAULT_MAX_MAP_ZOOM = 18
|
||||||
const ACCENT_COLOR: Color = [78, 165, 253]
|
const ACCENT_COLOR: Color = [78, 165, 253]
|
||||||
|
|
||||||
|
const config = useVisualizationConfig()
|
||||||
|
|
||||||
const dataPoints = ref<LocationWithPosition[]>([])
|
const dataPoints = ref<LocationWithPosition[]>([])
|
||||||
const mapNode = ref<HTMLElement>()
|
const mapNode = ref<HTMLElement>()
|
||||||
const latitude = ref(0)
|
const latitude = ref(0)
|
||||||
@ -410,18 +411,30 @@ function pushPoints(newPoints: Location[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.setToolbar([
|
||||||
|
{
|
||||||
|
icon: 'find',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'path2',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'geo_map_distance',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'geo_map_pin',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
config.setToolbarOverlay(true)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :overflow="true">
|
<div ref="mapNode" class="GeoMapVisualization" @pointerdown.stop @wheel.stop></div>
|
||||||
<template #toolbar>
|
|
||||||
<SvgButton name="find" />
|
|
||||||
<SvgButton name="path2" />
|
|
||||||
<SvgButton name="geo_map_distance" />
|
|
||||||
<SvgButton name="geo_map_pin" />
|
|
||||||
</template>
|
|
||||||
<div ref="mapNode" class="GeoMapVisualization" @pointerdown.stop @wheel.stop></div>
|
|
||||||
</VisualizationContainer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -41,7 +41,7 @@ interface Bucket {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { VisualizationContainer, useVisualizationConfig } from '@/util/visualizationBuiltins'
|
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||||
import { computed, ref, watchPostEffect } from 'vue'
|
import { computed, ref, watchPostEffect } from 'vue'
|
||||||
|
|
||||||
const d3 = await import('d3')
|
const d3 = await import('d3')
|
||||||
@ -87,20 +87,8 @@ const fill = computed(() =>
|
|||||||
.domain([0, d3.max(buckets.value, (d) => d.value) ?? 1]),
|
.domain([0, d3.max(buckets.value, (d) => d.value) ?? 1]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const width = ref(Math.max(config.width ?? 0, config.nodeSize.x))
|
const width = computed(() => config.size.x)
|
||||||
watchPostEffect(() => {
|
const height = computed(() => config.size.y)
|
||||||
width.value =
|
|
||||||
config.fullscreen ?
|
|
||||||
containerNode.value?.parentElement?.clientWidth ?? 0
|
|
||||||
: Math.max(config.width ?? 0, config.nodeSize.x)
|
|
||||||
})
|
|
||||||
const height = ref(config.height ?? (config.nodeSize.x * 3) / 4)
|
|
||||||
watchPostEffect(() => {
|
|
||||||
height.value =
|
|
||||||
config.fullscreen ?
|
|
||||||
containerNode.value?.parentElement?.clientHeight ?? 0
|
|
||||||
: config.height ?? (config.nodeSize.x * 3) / 4
|
|
||||||
})
|
|
||||||
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))
|
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))
|
||||||
const boxHeight = computed(() => Math.max(0, height.value - MARGIN.top - MARGIN.bottom))
|
const boxHeight = computed(() => Math.max(0, height.value - MARGIN.top - MARGIN.bottom))
|
||||||
|
|
||||||
@ -212,17 +200,15 @@ watchPostEffect(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :belowToolbar="true">
|
<div ref="containerNode" class="HeatmapVisualization">
|
||||||
<div ref="containerNode" class="HeatmapVisualization">
|
<svg :width="width" :height="height">
|
||||||
<svg :width="width" :height="height">
|
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">
|
||||||
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">
|
<g ref="xAxisNode" class="label label-x" :transform="`translate(0, ${boxHeight})`"></g>
|
||||||
<g ref="xAxisNode" class="label label-x" :transform="`translate(0, ${boxHeight})`"></g>
|
<g ref="yAxisNode" class="label label-y"></g>
|
||||||
<g ref="yAxisNode" class="label label-y"></g>
|
<g ref="pointsNode"></g>
|
||||||
<g ref="pointsNode"></g>
|
</g>
|
||||||
</g>
|
</svg>
|
||||||
</svg>
|
</div>
|
||||||
</div>
|
|
||||||
</VisualizationContainer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
|
||||||
import { useEvent } from '@/composables/events'
|
import { useEvent } from '@/composables/events'
|
||||||
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
|
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
|
||||||
import { defineKeybinds } from '@/util/shortcuts'
|
import { defineKeybinds } from '@/util/shortcuts'
|
||||||
import { VisualizationContainer, useVisualizationConfig } from '@/util/visualizationBuiltins'
|
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||||
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||||
|
|
||||||
export const name = 'Histogram'
|
export const name = 'Histogram'
|
||||||
@ -246,20 +245,8 @@ const margin = computed(() => ({
|
|||||||
bottom: MARGIN + (axis.value?.x?.label ? AXIS_LABEL_HEIGHT : 0),
|
bottom: MARGIN + (axis.value?.x?.label ? AXIS_LABEL_HEIGHT : 0),
|
||||||
left: MARGIN + (axis.value?.y?.label ? AXIS_LABEL_HEIGHT : 0),
|
left: MARGIN + (axis.value?.y?.label ? AXIS_LABEL_HEIGHT : 0),
|
||||||
}))
|
}))
|
||||||
const width = ref(Math.max(config.width ?? 0, config.nodeSize.x))
|
const width = computed(() => config.size.x)
|
||||||
watchPostEffect(() => {
|
const height = computed(() => config.size.y)
|
||||||
width.value =
|
|
||||||
config.fullscreen ?
|
|
||||||
containerNode.value?.parentElement?.clientWidth ?? 0
|
|
||||||
: Math.max(config.width ?? 0, config.nodeSize.x)
|
|
||||||
})
|
|
||||||
const height = ref(config.height ?? (config.nodeSize.x * 3) / 4)
|
|
||||||
watchPostEffect(() => {
|
|
||||||
height.value =
|
|
||||||
config.fullscreen ?
|
|
||||||
containerNode.value?.parentElement?.clientHeight ?? 0
|
|
||||||
: config.height ?? (config.nodeSize.x * 3) / 4
|
|
||||||
})
|
|
||||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
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 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 xLabelTop = computed(() => boxHeight.value + margin.value.bottom - AXIS_LABEL_HEIGHT / 2)
|
||||||
@ -568,63 +555,70 @@ useEvent(document, 'click', endBrushing)
|
|||||||
useEvent(document, 'auxclick', endBrushing)
|
useEvent(document, 'auxclick', endBrushing)
|
||||||
useEvent(document, 'contextmenu', endBrushing)
|
useEvent(document, 'contextmenu', endBrushing)
|
||||||
useEvent(document, 'scroll', endBrushing)
|
useEvent(document, 'scroll', endBrushing)
|
||||||
|
|
||||||
|
config.setToolbar([
|
||||||
|
{
|
||||||
|
icon: 'show_all',
|
||||||
|
title: 'Fit All',
|
||||||
|
onClick: () => zoomToSelected(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'find',
|
||||||
|
title: 'Zoom to Selected',
|
||||||
|
onClick: () => zoomToSelected(true),
|
||||||
|
},
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :belowToolbar="true">
|
<div ref="containerNode" class="HistogramVisualization" @pointerdown.stop>
|
||||||
<template #toolbar>
|
<svg :width="width" :height="height">
|
||||||
<SvgButton name="show_all" title="Fit All" @click="zoomToSelected(false)" />
|
<rect
|
||||||
<SvgButton name="find" title="Zoom to Selected" @click="zoomToSelected(true)" />
|
class="color-legend"
|
||||||
</template>
|
:width="COLOR_LEGEND_WIDTH"
|
||||||
<div ref="containerNode" class="HistogramVisualization" @pointerdown.stop>
|
:height="boxHeight"
|
||||||
<svg :width="width" :height="height">
|
:transform="`translate(${margin.left - COLOR_LEGEND_WIDTH}, ${margin.top})`"
|
||||||
<rect
|
:style="{ fill: 'url(#color-legend-gradient)' }"
|
||||||
class="color-legend"
|
/>
|
||||||
:width="COLOR_LEGEND_WIDTH"
|
<g :transform="`translate(${margin.left}, ${margin.top})`">
|
||||||
:height="boxHeight"
|
<defs>
|
||||||
:transform="`translate(${margin.left - COLOR_LEGEND_WIDTH}, ${margin.top})`"
|
<clipPath id="histogram-clip-path">
|
||||||
:style="{ fill: 'url(#color-legend-gradient)' }"
|
<rect :width="boxWidth" :height="boxHeight"></rect>
|
||||||
/>
|
</clipPath>
|
||||||
<g :transform="`translate(${margin.left}, ${margin.top})`">
|
<linearGradient
|
||||||
<defs>
|
id="color-legend-gradient"
|
||||||
<clipPath id="histogram-clip-path">
|
ref="colorLegendGradientNode"
|
||||||
<rect :width="boxWidth" :height="boxHeight"></rect>
|
x1="0%"
|
||||||
</clipPath>
|
y1="100%"
|
||||||
<linearGradient
|
x2="0%"
|
||||||
id="color-legend-gradient"
|
y2="0%"
|
||||||
ref="colorLegendGradientNode"
|
></linearGradient>
|
||||||
x1="0%"
|
</defs>
|
||||||
y1="100%"
|
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
|
||||||
x2="0%"
|
<g ref="yAxisNode" class="axis-y"></g>
|
||||||
y2="0%"
|
<text
|
||||||
></linearGradient>
|
v-if="axis.x?.label"
|
||||||
</defs>
|
class="label label-x"
|
||||||
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
|
text-anchor="end"
|
||||||
<g ref="yAxisNode" class="axis-y"></g>
|
:x="xLabelLeft"
|
||||||
<text
|
:y="xLabelTop"
|
||||||
v-if="axis.x?.label"
|
v-text="axis.x?.label"
|
||||||
class="label label-x"
|
></text>
|
||||||
text-anchor="end"
|
<text
|
||||||
:x="xLabelLeft"
|
v-if="axis.y?.label"
|
||||||
:y="xLabelTop"
|
class="label label-y"
|
||||||
v-text="axis.x?.label"
|
text-anchor="end"
|
||||||
></text>
|
:x="yLabelLeft"
|
||||||
<text
|
:y="yLabelTop"
|
||||||
v-if="axis.y?.label"
|
v-text="axis.y?.label"
|
||||||
class="label label-y"
|
></text>
|
||||||
text-anchor="end"
|
<g ref="plotNode" clip-path="url(#histogram-clip-path)"></g>
|
||||||
:x="yLabelLeft"
|
<g ref="zoomNode" class="zoom">
|
||||||
:y="yLabelTop"
|
<g ref="brushNode" class="brush"></g>
|
||||||
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>
|
</g>
|
||||||
</svg>
|
</g>
|
||||||
</div>
|
</svg>
|
||||||
</VisualizationContainer>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -10,7 +10,6 @@ interface Data {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { VisualizationContainer } from '@/util/visualizationBuiltins'
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ data: Data }>()
|
const props = defineProps<{ data: Data }>()
|
||||||
@ -23,11 +22,9 @@ const src = computed(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :belowNode="true">
|
<div class="ImageVisualization">
|
||||||
<div class="ImageVisualization">
|
<img :src="src" />
|
||||||
<img :src="src" />
|
</div>
|
||||||
</div>
|
|
||||||
</VisualizationContainer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -9,9 +9,10 @@ import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
|||||||
import JsonValueWidget from '@/components/visualizations/JSONVisualization/JsonValueWidget.vue'
|
import JsonValueWidget from '@/components/visualizations/JSONVisualization/JsonValueWidget.vue'
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import { Pattern } from '@/util/ast/match'
|
import { Pattern } from '@/util/ast/match'
|
||||||
import { useVisualizationConfig, VisualizationContainer } from '@/util/visualizationBuiltins'
|
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ data: unknown }>()
|
const { data } = defineProps<{ data: unknown }>()
|
||||||
|
|
||||||
const config = useVisualizationConfig()
|
const config = useVisualizationConfig()
|
||||||
|
|
||||||
@ -19,7 +20,7 @@ type ConstructivePattern = (placeholder: Ast.Owned) => Ast.Owned
|
|||||||
|
|
||||||
const JSON_OBJECT_TYPE = 'Standard.Base.Data.Json.JS_Object'
|
const JSON_OBJECT_TYPE = 'Standard.Base.Data.Json.JS_Object'
|
||||||
|
|
||||||
const isClickThroughEnabled = config.nodeType === JSON_OBJECT_TYPE
|
const isClickThroughEnabled = computed(() => config.nodeType === JSON_OBJECT_TYPE)
|
||||||
|
|
||||||
function projector(parentPattern: ConstructivePattern | undefined) {
|
function projector(parentPattern: ConstructivePattern | undefined) {
|
||||||
const style = {
|
const style = {
|
||||||
@ -55,15 +56,13 @@ function createProjection(path: (string | number)[][]) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :belowToolbar="true">
|
<div class="JSONVisualization">
|
||||||
<div class="JSONVisualization">
|
<JsonValueWidget
|
||||||
<JsonValueWidget
|
:data="data"
|
||||||
:data="props.data"
|
:class="{ viewonly: !isClickThroughEnabled }"
|
||||||
:class="{ viewonly: !isClickThroughEnabled }"
|
@createProjection="createProjection"
|
||||||
@createProjection="createProjection"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</VisualizationContainer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -4,7 +4,6 @@ export const inputType = 'Any'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { VisualizationContainer } from '@/util/visualizationBuiltins'
|
|
||||||
import { watchEffect } from 'vue'
|
import { watchEffect } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ data: { name: string; error: Error } }>()
|
const props = defineProps<{ data: { name: string; error: Error } }>()
|
||||||
@ -13,27 +12,15 @@ watchEffect(() => console.error(props.data.error))
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :belowToolbar="true">
|
<div class="LoadingErrorVisualization">
|
||||||
<div class="LoadingErrorVisualization">
|
<div>Could not load visualization '<span v-text="props.data.name"></span>':</div>
|
||||||
<div>
|
<div v-text="props.data.error.message"></div>
|
||||||
<span>Could not load visualization '<span v-text="props.data.name"></span>':</span>
|
</div>
|
||||||
<span v-text="props.data.error.message"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VisualizationContainer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.LoadingErrorVisualization {
|
.LoadingErrorVisualization {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
padding: 0 1em;
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.LoadingErrorVisualization > div {
|
|
||||||
display: flex;
|
|
||||||
flex-flow: column;
|
|
||||||
place-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,28 +1,17 @@
|
|||||||
<script lang="ts">
|
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner.vue'
|
|
||||||
import { VisualizationContainer } from '@/util/visualizationBuiltins'
|
|
||||||
|
|
||||||
export const name = 'Loading'
|
|
||||||
export const inputType = 'Any'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const _props = defineProps<{ data: unknown }>()
|
import LoadingSpinner from '@/components/shared/LoadingSpinner.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer>
|
<div class="LoadingVisualization">
|
||||||
<div class="LoadingVisualization">
|
<LoadingSpinner />
|
||||||
<LoadingSpinner />
|
</div>
|
||||||
</div>
|
|
||||||
</VisualizationContainer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.LoadingVisualization {
|
.LoadingVisualization {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-top: 30px;
|
|
||||||
place-content: center;
|
place-content: center;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
@ -37,7 +37,6 @@ interface Error {
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DEFAULT_THEME, type RGBA, type Theme } from '@/components/visualizations/builtins'
|
import { DEFAULT_THEME, type RGBA, type Theme } from '@/components/visualizations/builtins'
|
||||||
import { VisualizationContainer } from '@/util/visualizationBuiltins'
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
const sqlFormatter = await import('sql-formatter')
|
const sqlFormatter = await import('sql-formatter')
|
||||||
|
|
||||||
@ -123,13 +122,11 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :belowToolbar="true">
|
<div class="sql-visualization scrollable">
|
||||||
<div class="sql-visualization scrollable">
|
<pre v-if="data.error" class="sql" v-text="data.error"></pre>
|
||||||
<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. -->
|
||||||
<!-- 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>
|
||||||
<pre v-else class="sql" v-html="formatted"></pre>
|
</div>
|
||||||
</div>
|
|
||||||
</VisualizationContainer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
|
||||||
import { useEvent } from '@/composables/events'
|
import { useEvent } from '@/composables/events'
|
||||||
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import { tryNumberToEnso } from '@/util/ast/abstract'
|
import { tryNumberToEnso } from '@/util/ast/abstract'
|
||||||
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
|
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
|
||||||
import { VisualizationContainer, defineKeybinds } from '@/util/visualizationBuiltins'
|
import { defineKeybinds } from '@/util/visualizationBuiltins'
|
||||||
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||||
|
|
||||||
export const name = 'Scatter Plot'
|
export const name = 'Scatter Plot'
|
||||||
@ -115,9 +114,6 @@ interface DateObj {
|
|||||||
const d3 = await import('d3')
|
const d3 = await import('d3')
|
||||||
|
|
||||||
const props = defineProps<{ data: Partial<Data> | number[] }>()
|
const props = defineProps<{ data: Partial<Data> | number[] }>()
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const config = useVisualizationConfig()
|
const config = useVisualizationConfig()
|
||||||
|
|
||||||
@ -260,17 +256,8 @@ const margin = computed(() => {
|
|||||||
return { top: 10, right: 10, bottom: 35, left: 55 }
|
return { top: 10, right: 10, bottom: 35, left: 55 }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const width = computed(() =>
|
const width = computed(() => config.size.x)
|
||||||
config.fullscreen ?
|
const height = computed(() => config.size.y)
|
||||||
containerNode.value?.parentElement?.clientWidth ?? 0
|
|
||||||
: Math.max(config.width ?? 0, config.nodeSize.x),
|
|
||||||
)
|
|
||||||
|
|
||||||
const height = computed(() =>
|
|
||||||
config.fullscreen ?
|
|
||||||
containerNode.value?.parentElement?.clientHeight ?? 0
|
|
||||||
: config.height ?? (config.nodeSize.x * 3) / 4,
|
|
||||||
)
|
|
||||||
|
|
||||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
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 boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
||||||
@ -314,8 +301,7 @@ const xTickFormat = computed(() => {
|
|||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const boundsExpression =
|
const boundsExpression =
|
||||||
bounds.value != null ? Ast.Vector.tryBuild(bounds.value, tryNumberToEnso) : undefined
|
bounds.value != null ? Ast.Vector.tryBuild(bounds.value, tryNumberToEnso) : undefined
|
||||||
emit(
|
config.setPreprocessor(
|
||||||
'update:preprocessor',
|
|
||||||
'Standard.Visualization.Scatter_Plot',
|
'Standard.Visualization.Scatter_Plot',
|
||||||
'process_to_json_text',
|
'process_to_json_text',
|
||||||
boundsExpression?.code() ?? 'Nothing',
|
boundsExpression?.code() ?? 'Nothing',
|
||||||
@ -739,59 +725,62 @@ function zoomToSelected(override?: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEvent(document, 'keydown', bindings.handler({ zoomToSelected: () => zoomToSelected() }))
|
useEvent(document, 'keydown', bindings.handler({ zoomToSelected: () => zoomToSelected() }))
|
||||||
|
|
||||||
|
config.setToolbar([
|
||||||
|
{
|
||||||
|
icon: 'select',
|
||||||
|
title: 'Enable Selection',
|
||||||
|
toggle: selectionEnabled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'show_all',
|
||||||
|
title: 'Fit All',
|
||||||
|
onClick: () => zoomToSelected(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'find',
|
||||||
|
title: 'Zoom to Selected',
|
||||||
|
disabled: () => brushExtent.value == null,
|
||||||
|
onClick: zoomToSelected,
|
||||||
|
},
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :belowToolbar="true">
|
<div ref="containerNode" class="ScatterplotVisualization">
|
||||||
<template #toolbar>
|
<svg :width="width" :height="height">
|
||||||
<SvgButton
|
<g ref="legendNode"></g>
|
||||||
name="select"
|
<g :transform="`translate(${margin.left}, ${margin.top})`">
|
||||||
title="Enable Selection"
|
<defs>
|
||||||
@click="selectionEnabled = !selectionEnabled"
|
<clipPath id="clip">
|
||||||
/>
|
<rect :width="boxWidth" :height="boxHeight"></rect>
|
||||||
<SvgButton name="show_all" title="Fit All" @click.stop="zoomToSelected(false)" />
|
</clipPath>
|
||||||
<SvgButton
|
</defs>
|
||||||
name="zoom"
|
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
|
||||||
title="Zoom to Selected"
|
<g ref="yAxisNode" class="axis-y"></g>
|
||||||
:disabled="brushExtent == null"
|
<text
|
||||||
@click.stop="zoomToSelected"
|
v-if="data.axis.x.label"
|
||||||
/>
|
class="label label-x"
|
||||||
</template>
|
text-anchor="end"
|
||||||
<div ref="containerNode" class="ScatterplotVisualization">
|
:x="xLabelLeft"
|
||||||
<svg :width="width" :height="height">
|
:y="xLabelTop"
|
||||||
<g ref="legendNode"></g>
|
v-text="data.axis.x.label"
|
||||||
<g :transform="`translate(${margin.left}, ${margin.top})`">
|
></text>
|
||||||
<defs>
|
<text
|
||||||
<clipPath id="clip">
|
v-if="showYLabelText"
|
||||||
<rect :width="boxWidth" :height="boxHeight"></rect>
|
class="label label-y"
|
||||||
</clipPath>
|
text-anchor="end"
|
||||||
</defs>
|
:x="yLabelLeft"
|
||||||
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
|
:y="yLabelTop"
|
||||||
<g ref="yAxisNode" class="axis-y"></g>
|
v-text="data.axis.y.label"
|
||||||
<text
|
></text>
|
||||||
v-if="data.axis.x.label"
|
<g ref="pointsNode" clip-path="url(#clip)"></g>
|
||||||
class="label label-x"
|
<g ref="zoomNode" class="zoom" :width="boxWidth" :height="boxHeight" fill="none">
|
||||||
text-anchor="end"
|
<g ref="brushNode" class="brush"></g>
|
||||||
:x="xLabelLeft"
|
|
||||||
:y="xLabelTop"
|
|
||||||
v-text="data.axis.x.label"
|
|
||||||
></text>
|
|
||||||
<text
|
|
||||||
v-if="showYLabelText"
|
|
||||||
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>
|
</g>
|
||||||
</svg>
|
</g>
|
||||||
</div>
|
</svg>
|
||||||
</VisualizationContainer>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import icons from '@/assets/icons.svg'
|
import icons from '@/assets/icons.svg'
|
||||||
import { default as TableVizToolbar, type SortModel } from '@/components/TableVizToolbar.vue'
|
import AgGridTableView from '@/components/shared/AgGridTableView.vue'
|
||||||
import AgGridTableView from '@/components/widgets/AgGridTableView.vue'
|
import { SortModel, useTableVizToolbar } from '@/components/visualizations/tableVizToolbar'
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import { Pattern } from '@/util/ast/match'
|
import { Pattern } from '@/util/ast/match'
|
||||||
import { VisualizationContainer, useVisualizationConfig } from '@/util/visualizationBuiltins'
|
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||||
import '@ag-grid-community/styles/ag-grid.css'
|
|
||||||
import '@ag-grid-community/styles/ag-theme-alpine.css'
|
|
||||||
import type {
|
import type {
|
||||||
CellClassParams,
|
CellClassParams,
|
||||||
CellClickedEvent,
|
CellClickedEvent,
|
||||||
@ -84,17 +82,14 @@ interface UnknownTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum TextFormatOptions {
|
export enum TextFormatOptions {
|
||||||
Partial,
|
|
||||||
On,
|
On,
|
||||||
|
Partial,
|
||||||
Off,
|
Off,
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{ data: Data }>()
|
const props = defineProps<{ data: Data }>()
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
|
||||||
}>()
|
|
||||||
const config = useVisualizationConfig()
|
const config = useVisualizationConfig()
|
||||||
|
|
||||||
const INDEX_FIELD_NAME = '#'
|
const INDEX_FIELD_NAME = '#'
|
||||||
@ -135,10 +130,6 @@ const columnDefs: Ref<ColDef[]> = ref([])
|
|||||||
|
|
||||||
const textFormatterSelected = ref<TextFormatOptions>(TextFormatOptions.Partial)
|
const textFormatterSelected = ref<TextFormatOptions>(TextFormatOptions.Partial)
|
||||||
|
|
||||||
const updateTextFormat = (option: TextFormatOptions) => {
|
|
||||||
textFormatterSelected.value = option
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRowCountSelectorVisible = computed(() => rowCount.value >= 1000)
|
const isRowCountSelectorVisible = computed(() => rowCount.value >= 1000)
|
||||||
|
|
||||||
const selectableRowLimits = computed(() => {
|
const selectableRowLimits = computed(() => {
|
||||||
@ -266,8 +257,7 @@ function formatText(params: ICellRendererParams) {
|
|||||||
function setRowLimit(newRowLimit: number) {
|
function setRowLimit(newRowLimit: number) {
|
||||||
if (newRowLimit !== rowLimit.value) {
|
if (newRowLimit !== rowLimit.value) {
|
||||||
rowLimit.value = newRowLimit
|
rowLimit.value = newRowLimit
|
||||||
emit(
|
config.setPreprocessor(
|
||||||
'update:preprocessor',
|
|
||||||
'Standard.Visualization.Table.Visualization',
|
'Standard.Visualization.Table.Visualization',
|
||||||
'prepare_visualization',
|
'prepare_visualization',
|
||||||
newRowLimit.toString(),
|
newRowLimit.toString(),
|
||||||
@ -602,56 +592,60 @@ function checkSortAndFilter(e: SortChangedEvent) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setRowLimit(1000)
|
setRowLimit(1000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===============
|
||||||
|
// === Toolbar ===
|
||||||
|
// ===============
|
||||||
|
|
||||||
|
config.setToolbar(
|
||||||
|
useTableVizToolbar({
|
||||||
|
textFormatterSelected,
|
||||||
|
filterModel,
|
||||||
|
sortModel,
|
||||||
|
isDisabled: () => !isCreateNodeEnabled.value,
|
||||||
|
isFilterSortNodeEnabled,
|
||||||
|
createNodes: config.createNodes,
|
||||||
|
}),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :belowToolbar="true" :overflow="true" :toolbarOverflow="true">
|
<div ref="rootNode" class="TableVisualization" @wheel.stop @pointerdown.stop>
|
||||||
<template #toolbar>
|
<div class="table-visualization-status-bar">
|
||||||
<TableVizToolbar
|
<select
|
||||||
:filterModel="filterModel"
|
v-if="isRowCountSelectorVisible"
|
||||||
:sortModel="sortModel"
|
@change="setRowLimit(Number(($event.target as HTMLOptionElement).value))"
|
||||||
:isDisabled="!isCreateNodeEnabled"
|
>
|
||||||
:isFilterSortNodeEnabled="isFilterSortNodeEnabled"
|
<option
|
||||||
@changeFormat="(i) => updateTextFormat(i)"
|
v-for="limit in selectableRowLimits"
|
||||||
/>
|
:key="limit"
|
||||||
</template>
|
:value="limit"
|
||||||
<div ref="rootNode" class="TableVisualization" @wheel.stop @pointerdown.stop>
|
v-text="limit"
|
||||||
<div class="table-visualization-status-bar">
|
></option>
|
||||||
<select
|
</select>
|
||||||
v-if="isRowCountSelectorVisible"
|
<template v-if="showRowCount">
|
||||||
@change="setRowLimit(Number(($event.target as HTMLOptionElement).value))"
|
<span
|
||||||
>
|
v-if="isRowCountSelectorVisible && isTruncated"
|
||||||
<option
|
v-text="` of ${rowCount} rows (Sorting/Filtering disabled).`"
|
||||||
v-for="limit in selectableRowLimits"
|
></span>
|
||||||
:key="limit"
|
<span v-else-if="isRowCountSelectorVisible" v-text="' rows.'"></span>
|
||||||
:value="limit"
|
<span v-else-if="rowCount === 1" v-text="'1 row.'"></span>
|
||||||
v-text="limit"
|
<span v-else v-text="`${rowCount} rows.`"></span>
|
||||||
></option>
|
</template>
|
||||||
</select>
|
|
||||||
<template v-if="showRowCount">
|
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<!-- TODO[ao]: Suspence in theory is not needed here (the entire visualization is inside
|
|
||||||
suspense), but for some reason it causes reactivity loop - see https://github.com/enso-org/enso/issues/10782 -->
|
|
||||||
<Suspense>
|
|
||||||
<AgGridTableView
|
|
||||||
class="scrollable grid"
|
|
||||||
:columnDefs="columnDefs"
|
|
||||||
:rowData="rowData"
|
|
||||||
:defaultColDef="defaultColDef"
|
|
||||||
:textFormatOption="textFormatterSelected"
|
|
||||||
@sortOrFilterUpdated="(e) => checkSortAndFilter(e)"
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</VisualizationContainer>
|
<!-- TODO[ao]: Suspence in theory is not needed here (the entire visualization is inside
|
||||||
|
suspense), but for some reason it causes reactivity loop - see https://github.com/enso-org/enso/issues/10782 -->
|
||||||
|
<Suspense>
|
||||||
|
<AgGridTableView
|
||||||
|
class="scrollable grid"
|
||||||
|
:columnDefs="columnDefs"
|
||||||
|
:rowData="rowData"
|
||||||
|
:defaultColDef="defaultColDef"
|
||||||
|
:textFormatOption="textFormatterSelected"
|
||||||
|
@sortOrFilterUpdated="(e) => checkSortAndFilter(e)"
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
140
app/gui2/src/components/visualizations/VisualizationHost.vue
Normal file
140
app/gui2/src/components/visualizations/VisualizationHost.vue
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||||
|
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
|
||||||
|
import { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||||
|
import { provideVisualizationConfig } from '@/providers/visualizationConfig'
|
||||||
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
|
import { ToValue } from '@/util/reactivity'
|
||||||
|
|
||||||
|
const { visualization, data, size, nodeType } = defineProps<{
|
||||||
|
visualization?: string | object
|
||||||
|
data?: any
|
||||||
|
size: Vec2
|
||||||
|
nodeType?: string | undefined
|
||||||
|
overflow?: boolean
|
||||||
|
toolbarOverflow?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updatePreprocessor: [
|
||||||
|
visualizationModule: string,
|
||||||
|
expression: string,
|
||||||
|
...positionalArgumentsExpressions: string[],
|
||||||
|
]
|
||||||
|
updateToolbar: [items: ToValue<Readonly<ToolbarItem[]>>]
|
||||||
|
updateToolbarOverlay: [enable: boolean]
|
||||||
|
createNodes: [nodes: NodeCreationOptions[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// === Visualization API ===
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
provideVisualizationConfig({
|
||||||
|
get size() {
|
||||||
|
return size
|
||||||
|
},
|
||||||
|
get nodeType() {
|
||||||
|
return nodeType
|
||||||
|
},
|
||||||
|
setPreprocessor: (
|
||||||
|
visualizationModule: string,
|
||||||
|
expression: string,
|
||||||
|
...positionalArgumentsExpressions: string[]
|
||||||
|
) =>
|
||||||
|
emit('updatePreprocessor', visualizationModule, expression, ...positionalArgumentsExpressions),
|
||||||
|
setToolbar: (items) => emit('updateToolbar', items),
|
||||||
|
setToolbarOverlay: (overlay) => emit('updateToolbarOverlay', overlay),
|
||||||
|
createNodes: (...nodes) => emit('createNodes', nodes),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Suspense>
|
||||||
|
<template #fallback><LoadingVisualization /></template>
|
||||||
|
<component :is="visualization" v-if="visualization && data" :data="data" />
|
||||||
|
<LoadingVisualization v-else />
|
||||||
|
</Suspense>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([hidden]) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base style for visualizations. */
|
||||||
|
:host {
|
||||||
|
--color-text: rgb(118 118 118);
|
||||||
|
--font-sans: 'M PLUS 1', /* System sans-serif font stack */ system-ui, -apple-system,
|
||||||
|
BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
||||||
|
'Droid Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--font-mono: 'DejaVu Sans Mono', /* System monospace font stack */ ui-monospace, Menlo, Monaco,
|
||||||
|
'Cascadia Mono', 'Segoe UI Mono', 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace',
|
||||||
|
'Source Code Pro', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
pointer-events: all;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
-webkit-box-shadow: none;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar:vertical {
|
||||||
|
width: 11px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar:horizontal {
|
||||||
|
height: 11px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(220, 220, 220, 0.5);
|
||||||
|
background-color: rgba(190, 190, 190, 0.5);
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-corner {
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-button {
|
||||||
|
height: 8px;
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import { Pattern } from '@/util/ast/match'
|
import { Pattern } from '@/util/ast/match'
|
||||||
import { useVisualizationConfig, VisualizationContainer } from '@/util/visualizationBuiltins'
|
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
export const name = 'Warnings'
|
export const name = 'Warnings'
|
||||||
@ -24,28 +23,25 @@ type Data = string[]
|
|||||||
const props = defineProps<{ data: Data }>()
|
const props = defineProps<{ data: Data }>()
|
||||||
|
|
||||||
const config = useVisualizationConfig()
|
const config = useVisualizationConfig()
|
||||||
|
|
||||||
|
config.setToolbar([
|
||||||
|
{
|
||||||
|
icon: 'not_exclamation',
|
||||||
|
title: 'Remove Warnings',
|
||||||
|
disabled: () => props.data.length === 0,
|
||||||
|
onClick: () => config.createNodes({ content: removeWarnings.value, commit: true }),
|
||||||
|
dataTestid: 'remove-warnings-button',
|
||||||
|
},
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :belowToolbar="true">
|
<div class="WarningsVisualization">
|
||||||
<template #toolbar>
|
<ul>
|
||||||
<SvgButton
|
<li v-if="props.data.length === 0">There are no warnings.</li>
|
||||||
name="not_exclamation"
|
<li v-for="(warning, index) in props.data" :key="index" v-text="warning"></li>
|
||||||
data-testid="remove-warnings-button"
|
</ul>
|
||||||
title="Remove Warnings"
|
</div>
|
||||||
:disabled="props.data.length === 0"
|
|
||||||
@click="config.createNodes({ content: removeWarnings, commit: true })"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #default>
|
|
||||||
<div class="WarningsVisualization">
|
|
||||||
<ul>
|
|
||||||
<li v-if="props.data.length === 0">There are no warnings.</li>
|
|
||||||
<li v-for="(warning, index) in props.data" :key="index" v-text="warning"></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VisualizationContainer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
190
app/gui2/src/components/visualizations/tableVizToolbar.ts
Normal file
190
app/gui2/src/components/visualizations/tableVizToolbar.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||||
|
import { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue'
|
||||||
|
import { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||||
|
import { Ast } from '@/util/ast'
|
||||||
|
import { Pattern } from '@/util/ast/match'
|
||||||
|
import { ToValue } from '@/util/reactivity'
|
||||||
|
import { computed, ComputedRef, Ref, toValue } from 'vue'
|
||||||
|
|
||||||
|
type SortDirection = 'asc' | 'desc'
|
||||||
|
export type SortModel = {
|
||||||
|
columnName: string
|
||||||
|
sortDirection: SortDirection
|
||||||
|
sortIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortFilterNodesButtonOptions {
|
||||||
|
filterModel: ToValue<{
|
||||||
|
[key: string]: {
|
||||||
|
values: any[]
|
||||||
|
filterType: string
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
sortModel: ToValue<SortModel[]>
|
||||||
|
isDisabled: ToValue<boolean>
|
||||||
|
isFilterSortNodeEnabled: ToValue<boolean>
|
||||||
|
createNodes: (...options: NodeCreationOptions[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormatMenuOptions {
|
||||||
|
textFormatterSelected: Ref<TextFormatOptions>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Options extends SortFilterNodesButtonOptions, FormatMenuOptions {}
|
||||||
|
|
||||||
|
function useSortFilterNodesButton({
|
||||||
|
filterModel,
|
||||||
|
sortModel,
|
||||||
|
isDisabled,
|
||||||
|
isFilterSortNodeEnabled,
|
||||||
|
createNodes,
|
||||||
|
}: SortFilterNodesButtonOptions): ComputedRef<ToolbarItem | undefined> {
|
||||||
|
const sortPatternPattern = computed(() => Pattern.parse('(..Name __ __ )'))
|
||||||
|
|
||||||
|
const sortDirection = computed(() => ({
|
||||||
|
asc: '..Ascending',
|
||||||
|
desc: '..Descending',
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeSortPattern(module: Ast.MutableModule) {
|
||||||
|
const columnSortExpressions = toValue(sortModel)
|
||||||
|
.filter((sort) => sort?.columnName)
|
||||||
|
.sort((a, b) => a.sortIndex - b.sortIndex)
|
||||||
|
.map((sort) =>
|
||||||
|
sortPatternPattern.value.instantiateCopied([
|
||||||
|
Ast.TextLiteral.new(sort.columnName),
|
||||||
|
Ast.parse(sortDirection.value[sort.sortDirection as SortDirection]),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
return Ast.Vector.new(module, columnSortExpressions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterPattern = computed(() => Pattern.parse('__ (__ __)'))
|
||||||
|
|
||||||
|
function makeFilterPattern(module: Ast.MutableModule, columnName: string, items: string[]) {
|
||||||
|
if (
|
||||||
|
(items?.length === 1 && items.indexOf('true') != -1) ||
|
||||||
|
(items?.length === 1 && items.indexOf('false') != -1)
|
||||||
|
) {
|
||||||
|
const boolToInclude = items.indexOf('false') != -1 ? Ast.parse('False') : Ast.parse('True')
|
||||||
|
return filterPattern.value.instantiateCopied([
|
||||||
|
Ast.TextLiteral.new(columnName),
|
||||||
|
Ast.parse('..Equal'),
|
||||||
|
boolToInclude,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const itemList = items.map((i) => Ast.TextLiteral.new(i))
|
||||||
|
return filterPattern.value.instantiateCopied([
|
||||||
|
Ast.TextLiteral.new(columnName),
|
||||||
|
Ast.parse('..Is_In'),
|
||||||
|
Ast.Vector.new(module, itemList),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAstPatternSort() {
|
||||||
|
return Pattern.new((ast) =>
|
||||||
|
Ast.App.positional(
|
||||||
|
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('sort')!),
|
||||||
|
makeSortPattern(ast.module),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAstPatternFilter(columnName: string, items: string[]) {
|
||||||
|
return Pattern.new((ast) =>
|
||||||
|
Ast.App.positional(
|
||||||
|
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('filter')!),
|
||||||
|
makeFilterPattern(ast.module, columnName, items),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAstPatternFilterAndSort(columnName: string, items: string[]) {
|
||||||
|
return Pattern.new((ast) =>
|
||||||
|
Ast.OprApp.new(
|
||||||
|
ast.module,
|
||||||
|
Ast.App.positional(
|
||||||
|
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('filter')!),
|
||||||
|
makeFilterPattern(ast.module, columnName, items),
|
||||||
|
),
|
||||||
|
'.',
|
||||||
|
Ast.App.positional(
|
||||||
|
Ast.Ident.new(ast.module, Ast.identifier('sort')!),
|
||||||
|
makeSortPattern(ast.module),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewNodes() {
|
||||||
|
const patterns = new Array<Pattern>()
|
||||||
|
const filterModelValue = toValue(filterModel)
|
||||||
|
const sortModelValue = toValue(sortModel)
|
||||||
|
if (Object.keys(filterModelValue).length) {
|
||||||
|
for (const [columnName, columnFilter] of Object.entries(filterModelValue)) {
|
||||||
|
const items = columnFilter.values.map((item) => `${item}`)
|
||||||
|
const filterPatterns =
|
||||||
|
sortModelValue.length ?
|
||||||
|
getAstPatternFilterAndSort(columnName, items)
|
||||||
|
: getAstPatternFilter(columnName, items)
|
||||||
|
patterns.push(filterPatterns)
|
||||||
|
}
|
||||||
|
} else if (sortModelValue.length) {
|
||||||
|
patterns.push(getAstPatternSort())
|
||||||
|
}
|
||||||
|
createNodes(
|
||||||
|
...patterns.map(
|
||||||
|
(pattern) => ({ content: pattern, commit: true }) satisfies NodeCreationOptions,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNodesButton: ToolbarItem = {
|
||||||
|
icon: 'add',
|
||||||
|
title:
|
||||||
|
"Create new component(s) with the current grid's sort and filters applied to the workflow",
|
||||||
|
disabled: isDisabled,
|
||||||
|
onClick: createNewNodes,
|
||||||
|
}
|
||||||
|
|
||||||
|
return computed(() => (toValue(isFilterSortNodeEnabled) ? createNodesButton : undefined))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFormatMenu({ textFormatterSelected }: FormatMenuOptions): ToolbarItem {
|
||||||
|
return {
|
||||||
|
selected: textFormatterSelected,
|
||||||
|
title: 'Text Display Options',
|
||||||
|
options: {
|
||||||
|
[TextFormatOptions.On]: {
|
||||||
|
icon: 'paragraph',
|
||||||
|
iconStyle: {
|
||||||
|
stroke: 'black',
|
||||||
|
color: 'black',
|
||||||
|
},
|
||||||
|
title:
|
||||||
|
'Text displayed in monospace font and all whitespace characters displayed as symbols',
|
||||||
|
label: 'Full whitespace rendering',
|
||||||
|
},
|
||||||
|
[TextFormatOptions.Partial]: {
|
||||||
|
icon: 'paragraph',
|
||||||
|
iconStyle: {
|
||||||
|
stroke: 'grey',
|
||||||
|
color: 'grey',
|
||||||
|
},
|
||||||
|
title: 'Text displayed in monospace font, only multiple spaces displayed with ·',
|
||||||
|
label: 'Partial whitespace rendering',
|
||||||
|
},
|
||||||
|
[TextFormatOptions.Off]: {
|
||||||
|
icon: 'not_paragraph',
|
||||||
|
title: 'No formatting applied to text',
|
||||||
|
label: 'No whitespace rendering',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTableVizToolbar(options: Options): ComputedRef<ToolbarItem[]> {
|
||||||
|
const createNodesButton = useSortFilterNodesButton(options)
|
||||||
|
const formatMenu = createFormatMenu(options)
|
||||||
|
return computed(() => [formatMenu, ...(createNodesButton.value ? [createNodesButton.value] : [])])
|
||||||
|
}
|
45
app/gui2/src/components/visualizations/toolbar.ts
Normal file
45
app/gui2/src/components/visualizations/toolbar.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { URLString } from '@/util/data/urlString'
|
||||||
|
import { Icon } from '@/util/iconName'
|
||||||
|
import { ToValue } from '@/util/reactivity'
|
||||||
|
import { Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface Button {
|
||||||
|
icon: Icon | URLString
|
||||||
|
iconStyle?: Record<string, string>
|
||||||
|
title?: string
|
||||||
|
dataTestid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionButton extends Button {
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: ToValue<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToggleButton extends Button {
|
||||||
|
toggle: Ref<boolean>
|
||||||
|
disabled?: ToValue<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionMenuOption extends Button {
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionMenu {
|
||||||
|
selected: Ref<number | string | symbol>
|
||||||
|
title?: string
|
||||||
|
options: Record<number | string | symbol, SelectionMenuOption>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolbarItem = ActionButton | ToggleButton | SelectionMenu
|
||||||
|
|
||||||
|
export function isActionButton(item: Readonly<ToolbarItem>): item is ActionButton {
|
||||||
|
return 'onClick' in item
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isToggleButton(item: Readonly<ToolbarItem>): item is ToggleButton {
|
||||||
|
return 'toggle' in item
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelectionMenu(item: Readonly<ToolbarItem>): item is SelectionMenu {
|
||||||
|
return 'selected' in item
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/shared/LoadingSpinner.vue'
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
import SvgButton from '@/components/SvgButton.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import { useBackendQuery, useBackendQueryPrefetching } from '@/composables/backend'
|
import { useBackendQuery, useBackendQueryPrefetching } from '@/composables/backend'
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { createContextStore } from '@/providers'
|
import { createContextStore } from '@/providers'
|
||||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
|
||||||
import { shallowRef, watch, type WatchSource } from 'vue'
|
import { shallowRef, watch, type WatchSource } from 'vue'
|
||||||
|
|
||||||
export { injectFn as injectInteractionHandler, provideFn as provideInteractionHandler }
|
export { injectFn as injectInteractionHandler, provideFn as provideInteractionHandler }
|
||||||
@ -69,12 +68,11 @@ export class InteractionHandler {
|
|||||||
event: PointerEvent,
|
event: PointerEvent,
|
||||||
handlerName: Interaction[HandlerName] extends InteractionEventHandler | undefined ? HandlerName
|
handlerName: Interaction[HandlerName] extends InteractionEventHandler | undefined ? HandlerName
|
||||||
: never,
|
: never,
|
||||||
graphNavigator: GraphNavigator,
|
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!this.currentInteraction.value) return false
|
if (!this.currentInteraction.value) return false
|
||||||
const handler = this.currentInteraction.value[handlerName]
|
const handler = this.currentInteraction.value[handlerName]
|
||||||
if (!handler) return false
|
if (!handler) return false
|
||||||
const handled = handler.bind(this.currentInteraction.value)(event, graphNavigator) !== false
|
const handled = handler.bind(this.currentInteraction.value)(event) !== false
|
||||||
if (handled) {
|
if (handled) {
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@ -83,7 +81,7 @@ export class InteractionHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type InteractionEventHandler = (event: PointerEvent, navigator: GraphNavigator) => boolean | void
|
type InteractionEventHandler = (event: PointerEvent) => boolean | void
|
||||||
|
|
||||||
export interface Interaction {
|
export interface Interaction {
|
||||||
/** Called when the interaction is explicitly canceled, e.g. with the `Esc` key. */
|
/** Called when the interaction is explicitly canceled, e.g. with the `Esc` key. */
|
||||||
|
@ -1,35 +1,30 @@
|
|||||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
import { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||||
import { SavedSize } from '@/components/WithFullscreenMode.vue'
|
import { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||||
import { createContextStore } from '@/providers'
|
import { createContextStore } from '@/providers'
|
||||||
import type { URLString } from '@/util/data/urlString'
|
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
import type { Icon } from '@/util/iconName'
|
import { ToValue } from '@/util/reactivity'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import type { VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
|
||||||
|
|
||||||
export interface VisualizationConfig {
|
export interface VisualizationConfig {
|
||||||
background?: string
|
/** The Enso type of the data being visualized. */
|
||||||
/** Possible visualization types that can be switched to. */
|
|
||||||
readonly types: Iterable<VisualizationIdentifier>
|
|
||||||
readonly currentType: VisualizationIdentifier
|
|
||||||
readonly icon: Icon | URLString | undefined
|
|
||||||
readonly isCircularMenuVisible: boolean
|
|
||||||
readonly nodeSize: Vec2
|
|
||||||
readonly scale: number
|
|
||||||
readonly isFocused: boolean
|
|
||||||
readonly nodeType: string | undefined
|
readonly nodeType: string | undefined
|
||||||
readonly isPreview: boolean
|
/** The size of the area available for the visualization to draw its content. */
|
||||||
readonly isFullscreenAllowed: boolean
|
readonly size: Vec2
|
||||||
readonly isResizable: boolean
|
/** Create graph nodes. */
|
||||||
isBelowToolbar: boolean
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
nodePosition: Vec2
|
|
||||||
fullscreen: boolean
|
|
||||||
savedSize: SavedSize | undefined
|
|
||||||
hide: () => void
|
|
||||||
updateType: (type: VisualizationIdentifier) => void
|
|
||||||
createNodes: (...options: NodeCreationOptions[]) => void
|
createNodes: (...options: NodeCreationOptions[]) => void
|
||||||
|
/** Set the preprocessor that prepares the visualization data on the backend. */
|
||||||
|
setPreprocessor: (
|
||||||
|
visualizationModule: string,
|
||||||
|
expression: string,
|
||||||
|
...positionalArgumentsExpressions: string[]
|
||||||
|
) => void
|
||||||
|
/** Provide a toolbar definition. */
|
||||||
|
setToolbar: (toolbar: ToValue<Readonly<ToolbarItem[]>>) => void
|
||||||
|
/**
|
||||||
|
* If set to `true`, the toolbar will be overlayed on top of the visualization, instead of in a space reserved above
|
||||||
|
* it. By default, this is `false`.
|
||||||
|
*/
|
||||||
|
setToolbarOverlay: (enableOverlay: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export { provideFn as provideVisualizationConfig }
|
export { provideFn as provideVisualizationConfig }
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
|
||||||
import { InteractionHandler } from '@/providers/interactionHandler'
|
import { InteractionHandler } from '@/providers/interactionHandler'
|
||||||
import type { PortId } from '@/providers/portInfo'
|
import type { PortId } from '@/providers/portInfo'
|
||||||
import { useCurrentEdit, type CurrentEdit } from '@/providers/widgetTree'
|
import { useCurrentEdit, type CurrentEdit } from '@/providers/widgetTree'
|
||||||
@ -130,7 +129,6 @@ test.each`
|
|||||||
'Handling clicks in WidgetEditHandlers case $name',
|
'Handling clicks in WidgetEditHandlers case $name',
|
||||||
({ widgets, edited, propagatingHandlers, nonPropagatingHandlers, expectedHandlerCalls }) => {
|
({ widgets, edited, propagatingHandlers, nonPropagatingHandlers, expectedHandlerCalls }) => {
|
||||||
const event = new MouseEvent('pointerdown') as PointerEvent
|
const event = new MouseEvent('pointerdown') as PointerEvent
|
||||||
const navigator = {} as GraphNavigator
|
|
||||||
const interactionHandler = new InteractionHandler()
|
const interactionHandler = new InteractionHandler()
|
||||||
const widgetTree = proxyRefs(useCurrentEdit())
|
const widgetTree = proxyRefs(useCurrentEdit())
|
||||||
|
|
||||||
@ -144,24 +142,22 @@ test.each`
|
|||||||
(id) =>
|
(id) =>
|
||||||
propagatingHandlersSet.has(id) ?
|
propagatingHandlersSet.has(id) ?
|
||||||
{
|
{
|
||||||
pointerdown: vi.fn((e, nav) => {
|
pointerdown: vi.fn((e) => {
|
||||||
expect(e).toBe(event)
|
expect(e).toBe(event)
|
||||||
expect(nav).toBe(navigator)
|
|
||||||
return false
|
return false
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: nonPropagatingHandlersSet.has(id) ?
|
: nonPropagatingHandlersSet.has(id) ?
|
||||||
{
|
{
|
||||||
pointerdown: vi.fn((e, nav) => {
|
pointerdown: vi.fn((e) => {
|
||||||
expect(e).toBe(event)
|
expect(e).toBe(event)
|
||||||
expect(nav).toBe(navigator)
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
widgetTree,
|
widgetTree,
|
||||||
)
|
)
|
||||||
handlers.get(edited)?.handler.start()
|
handlers.get(edited)?.handler.start()
|
||||||
interactionHandler.handlePointerEvent(event, 'pointerdown', navigator)
|
interactionHandler.handlePointerEvent(event, 'pointerdown')
|
||||||
const handlersCalled = new Set<string>()
|
const handlersCalled = new Set<string>()
|
||||||
for (const [id, { interaction }] of handlers)
|
for (const [id, { interaction }] of handlers)
|
||||||
if ((interaction.pointerdown as Mock | undefined)?.mock.lastCall) handlersCalled.add(id)
|
if ((interaction.pointerdown as Mock | undefined)?.mock.lastCall) handlersCalled.add(id)
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
|
||||||
import type { Interaction, InteractionHandler } from '@/providers/interactionHandler'
|
import type { Interaction, InteractionHandler } from '@/providers/interactionHandler'
|
||||||
import { injectInteractionHandler } from '@/providers/interactionHandler'
|
import { injectInteractionHandler } from '@/providers/interactionHandler'
|
||||||
import type { PortId } from '@/providers/portInfo'
|
import type { PortId } from '@/providers/portInfo'
|
||||||
@ -69,10 +68,9 @@ export abstract class WidgetEditHandlerParent {
|
|||||||
return this.hooks.addItem?.() ?? this.parent?.addItem() ?? false
|
return this.hooks.addItem?.() ?? this.parent?.addItem() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
protected pointerdown(event: PointerEvent, navigator: GraphNavigator): boolean | void {
|
protected pointerdown(event: PointerEvent): boolean | void {
|
||||||
if (this.hooks.pointerdown && this.hooks.pointerdown(event, navigator) !== false) return true
|
if (this.hooks.pointerdown && this.hooks.pointerdown(event) !== false) return true
|
||||||
else
|
else return this.activeChild.value ? this.activeChild.value.pointerdown(event) : false
|
||||||
return this.activeChild.value ? this.activeChild.value.pointerdown(event, navigator) : false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive() {
|
isActive() {
|
||||||
@ -165,8 +163,8 @@ export class WidgetEditHandlerRoot extends WidgetEditHandlerParent implements In
|
|||||||
this.onEnd()
|
this.onEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
override pointerdown(event: PointerEvent, navigator: GraphNavigator) {
|
override pointerdown(event: PointerEvent) {
|
||||||
return super.pointerdown(event, navigator)
|
return super.pointerdown(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override root() {
|
protected override root() {
|
||||||
|
@ -118,12 +118,12 @@ function handleClick(
|
|||||||
const chainedPointerdown = interaction.pointerdown
|
const chainedPointerdown = interaction.pointerdown
|
||||||
const wrappedInteraction: Interaction = {
|
const wrappedInteraction: Interaction = {
|
||||||
...interaction,
|
...interaction,
|
||||||
pointerdown: (e: PointerEvent, ...args) => {
|
pointerdown: (e: PointerEvent) => {
|
||||||
if (condition(e)) {
|
if (condition(e)) {
|
||||||
handler(wrappedInteraction)
|
handler(wrappedInteraction)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return chainedPointerdown ? chainedPointerdown(e, ...args) : false
|
return chainedPointerdown ? chainedPointerdown(e) : false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return wrappedInteraction
|
return wrappedInteraction
|
||||||
|
@ -1,3 +1,2 @@
|
|||||||
export { default as VisualizationContainer } from '@/components/VisualizationContainer.vue'
|
|
||||||
export { useVisualizationConfig } from '@/providers/visualizationConfig'
|
export { useVisualizationConfig } from '@/providers/visualizationConfig'
|
||||||
export { defineKeybinds } from '@/util/shortcuts'
|
export { defineKeybinds } from '@/util/shortcuts'
|
||||||
|
@ -28,14 +28,11 @@ export const inputType = 'Any'
|
|||||||
\x3c/script>
|
\x3c/script>
|
||||||
|
|
||||||
\x3cscript setup lang="ts">
|
\x3cscript setup lang="ts">
|
||||||
import { VisualizationContainer } from 'builtins'
|
|
||||||
const props = defineProps<{ data: unknown }>()
|
const props = defineProps<{ data: unknown }>()
|
||||||
\x3c/script>
|
\x3c/script>
|
||||||
|
|
||||||
\x3ctemplate>
|
\x3ctemplate>
|
||||||
<VisualizationContainer :belowToolbar="true">
|
<pre><code class="green-text" v-text="props.data"></code></pre>
|
||||||
<pre><code class="green-text" v-text="props.data"></code></pre>
|
|
||||||
</VisualizationContainer>
|
|
||||||
\x3c/template>
|
\x3c/template>
|
||||||
|
|
||||||
\x3cstyle scoped>
|
\x3cstyle scoped>
|
||||||
|
@ -102,42 +102,12 @@ export const setupVue3 = defineSetupVue3(({ app, addWrapper }) => {
|
|||||||
// Required for visualization stories.
|
// Required for visualization stories.
|
||||||
provideVisualizationConfig._mock(
|
provideVisualizationConfig._mock(
|
||||||
{
|
{
|
||||||
fullscreen: false,
|
size: new Vec2(200, 150),
|
||||||
isFullscreenAllowed: true,
|
|
||||||
isResizable: true,
|
|
||||||
savedSize: undefined,
|
|
||||||
scale: 1,
|
|
||||||
width: 200,
|
|
||||||
height: 150,
|
|
||||||
hide() {},
|
|
||||||
isCircularMenuVisible: false,
|
|
||||||
isBelowToolbar: false,
|
|
||||||
nodeSize: new Vec2(200, 150),
|
|
||||||
currentType: {
|
|
||||||
module: { kind: 'Builtin' },
|
|
||||||
name: 'Current Type',
|
|
||||||
},
|
|
||||||
icon: 'braces',
|
|
||||||
types: [
|
|
||||||
{
|
|
||||||
module: { kind: 'Builtin' },
|
|
||||||
name: 'Example',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
module: { kind: 'Builtin' },
|
|
||||||
name: 'Types',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
module: { kind: 'Builtin' },
|
|
||||||
name: 'Here',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
updateType() {},
|
|
||||||
createNodes() {},
|
createNodes() {},
|
||||||
isFocused: false,
|
|
||||||
isPreview: false,
|
|
||||||
nodePosition: Vec2.Zero,
|
|
||||||
nodeType: 'component',
|
nodeType: 'component',
|
||||||
|
setPreprocessor: () => {},
|
||||||
|
setToolbar: () => {},
|
||||||
|
setToolbarOverlay: () => {},
|
||||||
},
|
},
|
||||||
app,
|
app,
|
||||||
)
|
)
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
cursor: pointer !important;
|
cursor: pointer !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.VisualizationContainer {
|
.GraphVisualization {
|
||||||
z-index: 0 !important;
|
z-index: 0 !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,6 @@ interface Data {
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
import { VisualizationContainer } from 'builtins'
|
|
||||||
// Optional: add your own external dependencies.
|
// Optional: add your own external dependencies.
|
||||||
// import dependency from 'https://<js dependency here>'
|
// import dependency from 'https://<js dependency here>'
|
||||||
//
|
//
|
||||||
@ -43,10 +42,8 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer>
|
<!-- <content here> -->
|
||||||
<!-- <content here> -->
|
{{ props.data }}
|
||||||
{{ props.data }}
|
|
||||||
</VisualizationContainer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -33,7 +33,14 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
wasm(),
|
wasm(),
|
||||||
...(process.env.NODE_ENV === 'development' ? [await VueDevTools()] : []),
|
...(process.env.NODE_ENV === 'development' ? [await VueDevTools()] : []),
|
||||||
vue(),
|
vue({
|
||||||
|
customElement: ['**/components/visualizations/**', '**/components/shared/**'],
|
||||||
|
template: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: (tag) => tag.startsWith('enso-'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
react({
|
react({
|
||||||
include: fileURLToPath(new URL('../dashboard/**/*.tsx', import.meta.url)),
|
include: fileURLToPath(new URL('../dashboard/**/*.tsx', import.meta.url)),
|
||||||
babel: { plugins: ['@babel/plugin-syntax-import-attributes'] },
|
babel: { plugins: ['@babel/plugin-syntax-import-attributes'] },
|
||||||
|
Loading…
Reference in New Issue
Block a user