mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 23:31:42 +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 codeEditor = componentLocator('.CodeEditor')
|
||||
export const anyVisualization = componentLocator('.GraphVisualization > *')
|
||||
export const anyVisualization = componentLocator('.GraphVisualization')
|
||||
export const loadingVisualization = componentLocator('.LoadingVisualization')
|
||||
export const circularMenu = componentLocator('.CircularMenu')
|
||||
export const addNewNodeButton = componentLocator('.PlusButton')
|
||||
@ -141,15 +141,26 @@ export function bottomDock(page: Page) {
|
||||
|
||||
export const navBreadcrumb = componentLocator('.NavBreadcrumb')
|
||||
export const componentBrowserInput = componentLocator('.ComponentEditor')
|
||||
export const jsonVisualization = componentLocator('.JSONVisualization')
|
||||
export const tableVisualization = componentLocator('.TableVisualization')
|
||||
export const scatterplotVisualization = componentLocator('.ScatterplotVisualization')
|
||||
export const histogramVisualization = componentLocator('.HistogramVisualization')
|
||||
export const heatmapVisualization = componentLocator('.HeatmapVisualization')
|
||||
export const sqlVisualization = componentLocator('.SqlVisualization')
|
||||
export const geoMapVisualization = componentLocator('.GeoMapVisualization')
|
||||
export const imageBase64Visualization = componentLocator('.ImageBase64Visualization')
|
||||
export const warningsVisualization = componentLocator('.WarningsVisualization')
|
||||
|
||||
function visualizationLocator(visSelector: string) {
|
||||
// Playwright pierces shadow roots, but not within a single XPath.
|
||||
// Locate the visualization content, then locate the descendant.
|
||||
const visLocator = componentLocator(visSelector)
|
||||
return (page: Locator | Page, filter?: (f: Filter) => { selector: string }) => {
|
||||
const hostLocator = page.locator('.VisualizationHostContainer')
|
||||
return visLocator(hostLocator, filter)
|
||||
}
|
||||
}
|
||||
|
||||
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 ===
|
||||
|
||||
|
@ -130,38 +130,31 @@ registerAutoBlurHandler()
|
||||
|
||||
:deep(.scrollable) {
|
||||
scrollbar-color: rgba(190 190 190 / 50%) transparent;
|
||||
}
|
||||
|
||||
:deep(.scrollable)::-webkit-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
:deep(.scrollable)::-webkit-scrollbar-track {
|
||||
&::-webkit-scrollbar-track {
|
||||
-webkit-box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.scrollable)::-webkit-scrollbar:vertical {
|
||||
&::-webkit-scrollbar:vertical {
|
||||
width: 11px;
|
||||
}
|
||||
|
||||
:deep(.scrollable)::-webkit-scrollbar:horizontal {
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
:deep(.scrollable)::-webkit-scrollbar-thumb {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(220, 220, 220, 0.5);
|
||||
background-color: rgba(190, 190, 190, 0.5);
|
||||
}
|
||||
|
||||
:deep(.scrollable)::-webkit-scrollbar-corner {
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
:deep(.scrollable)::-webkit-scrollbar-button {
|
||||
&::-webkit-scrollbar-button {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.draggable) {
|
||||
cursor: grab;
|
||||
|
@ -11,6 +11,7 @@ const open = defineModel<boolean>('open', { default: false })
|
||||
const props = defineProps<{
|
||||
title?: string | undefined
|
||||
placement?: Placement
|
||||
alwaysShowArrow?: boolean | undefined
|
||||
}>()
|
||||
|
||||
const rootElement = shallowRef<HTMLElement>()
|
||||
@ -42,7 +43,11 @@ const { floatingStyles } = useFloating(rootElement, floatElement, {
|
||||
>
|
||||
<slot name="button" />
|
||||
</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">
|
||||
<div v-if="open" ref="floatElement" class="DropdownMenuContent" :style="floatingStyles">
|
||||
<slot name="entries" />
|
||||
|
@ -279,23 +279,13 @@ useEvent(window, 'keydown', (event) => {
|
||||
(!keyboardBusy() && graphNavigator.keyboardEvents.keydown(event))
|
||||
})
|
||||
|
||||
useEvent(
|
||||
window,
|
||||
'pointerdown',
|
||||
(e) => interaction.handlePointerEvent(e, 'pointerdown', graphNavigator),
|
||||
{
|
||||
useEvent(window, 'pointerdown', (e) => interaction.handlePointerEvent(e, 'pointerdown'), {
|
||||
capture: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
useEvent(
|
||||
window,
|
||||
'pointerup',
|
||||
(e) => interaction.handlePointerEvent(e, 'pointerup', graphNavigator),
|
||||
{
|
||||
useEvent(window, 'pointerup', (e) => interaction.handlePointerEvent(e, 'pointerup'), {
|
||||
capture: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// === Keyboard/Mouse bindings ===
|
||||
|
||||
|
@ -27,21 +27,20 @@ const MIN_DRAG_MOVE = 10
|
||||
const editingEdge: Interaction = {
|
||||
cancel: () => (graph.mouseEditedEdge = undefined),
|
||||
end: () => (graph.mouseEditedEdge = undefined),
|
||||
pointerdown: (_e: PointerEvent, graphNavigator: GraphNavigator) =>
|
||||
edgeInteractionClick(graphNavigator),
|
||||
pointerup: (e: PointerEvent, graphNavigator: GraphNavigator) => {
|
||||
pointerdown: edgeInteractionClick,
|
||||
pointerup: (e: PointerEvent) => {
|
||||
const originEvent = graph.mouseEditedEdge?.event
|
||||
if (originEvent?.type === 'pointerdown') {
|
||||
const delta = new Vec2(e.screenX, e.screenY).sub(
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
function edgeInteractionClick(graphNavigator: GraphNavigator) {
|
||||
function edgeInteractionClick() {
|
||||
if (graph.mouseEditedEdge == null) return false
|
||||
let source: AstId | undefined
|
||||
let sourceNode: NodeId | undefined
|
||||
@ -59,7 +58,7 @@ function edgeInteractionClick(graphNavigator: GraphNavigator) {
|
||||
if (target == null) {
|
||||
if (graph.mouseEditedEdge?.disconnectedEdgeTarget != null)
|
||||
disconnectEdge(graph.mouseEditedEdge.disconnectedEdgeTarget)
|
||||
emits('createNodeFromEdge', source, graphNavigator.sceneMousePos ?? Vec2.Zero)
|
||||
emits('createNodeFromEdge', source, props.navigator.sceneMousePos ?? Vec2.Zero)
|
||||
} else {
|
||||
createEdge(source, target)
|
||||
}
|
||||
|
@ -1,41 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
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 LoadingErrorVisualization from '@/components/visualizations/LoadingErrorVisualization.vue'
|
||||
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
|
||||
import { SavedSize } from '@/components/WithFullscreenMode.vue'
|
||||
import { focusIsIn, useEvent } from '@/composables/events'
|
||||
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 ResizeHandles from '@/components/ResizeHandles.vue'
|
||||
import WithFullscreenMode from '@/components/WithFullscreenMode.vue'
|
||||
import { focusIsIn, useEvent, useResizeObserver } from '@/composables/events'
|
||||
import { VisualizationDataSource } from '@/stores/visualization'
|
||||
import type { Opt } from '@/util/data/opt'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import type { Result } from '@/util/data/result'
|
||||
import type { URLString } from '@/util/data/urlString'
|
||||
import { type BoundsSet, Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
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 { computed, nextTick, onUnmounted, ref, toRef, watch, watchEffect } from 'vue'
|
||||
import { visIdentifierEquals, type VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||
|
||||
/** 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_CONTENT_HEIGHT_PX = 32
|
||||
const DEFAULT_CONTENT_HEIGHT_PX = 150
|
||||
const TOOLBAR_HEIGHT_PX = 36
|
||||
|
||||
// Used for testing.
|
||||
type RawDataSource = { type: 'raw'; data: any }
|
||||
|
||||
const props = defineProps<{
|
||||
currentType?: Opt<VisualizationIdentifier>
|
||||
@ -74,287 +48,41 @@ const emit = defineEmits<{
|
||||
createNodes: [options: NodeCreationOptions[]]
|
||||
}>()
|
||||
|
||||
const visPreprocessor = ref(DEFAULT_VISUALIZATION_CONFIGURATION)
|
||||
const vueError = ref<Error>()
|
||||
// ===================================
|
||||
// === Visualization-Specific Data ===
|
||||
// ===================================
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const visualizationStore = useVisualizationStore()
|
||||
|
||||
const configForGettingDefaultVisualization = computed<NodeVisualizationConfiguration | undefined>(
|
||||
() => {
|
||||
if (props.currentType) return
|
||||
if (props.dataSource?.type !== 'node') return
|
||||
return {
|
||||
visualizationModule: 'Standard.Visualization.Helpers',
|
||||
expression: 'a -> a.default_visualization.to_js_object.to_json',
|
||||
expressionId: props.dataSource.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(() => {
|
||||
if (props.currentType) return props.currentType
|
||||
if (defaultVisualizationForCurrentNodeSource.value)
|
||||
return defaultVisualizationForCurrentNodeSource.value
|
||||
const [id] = visualizationStore.types(props.typename)
|
||||
return id
|
||||
const {
|
||||
effectiveVisualization,
|
||||
effectiveVisualizationData,
|
||||
updatePreprocessor,
|
||||
allTypes,
|
||||
currentType,
|
||||
setToolbarDefinition,
|
||||
visualizationDefinedToolbar,
|
||||
toolbarOverlay,
|
||||
} = useVisualizationData({
|
||||
selectedVis: toRef(props, 'currentType'),
|
||||
dataSource: toRef(props, 'dataSource'),
|
||||
typename: toRef(props, 'typename'),
|
||||
})
|
||||
|
||||
const visualization = shallowRef<Visualization>()
|
||||
const icon = ref<Icon | URLString>()
|
||||
// ===========
|
||||
// === DOM ===
|
||||
// ===========
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
vueError.value = error
|
||||
return false
|
||||
})
|
||||
/** Includes content and toolbars. */
|
||||
const panelElement = ref<HTMLElement>()
|
||||
|
||||
const nodeVisualizationData = projectStore.useVisualizationData(() => {
|
||||
if (props.dataSource?.type !== 'node') return
|
||||
return {
|
||||
...visPreprocessor.value,
|
||||
expressionId: props.dataSource.nodeId,
|
||||
}
|
||||
})
|
||||
/** Contains only the visualization itself. */
|
||||
const contentElement = ref<HTMLElement>()
|
||||
const contentElementSize = useResizeObserver(contentElement)
|
||||
|
||||
const expressionVisualizationData = computedAsync(() => {
|
||||
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>()
|
||||
// === Events ===
|
||||
|
||||
const keydownHandler = visualizationBindings.handler({
|
||||
nextType: () => {
|
||||
if (props.isFocused || focusIsIn(root.value)) {
|
||||
if (props.isFocused || focusIsIn(panelElement.value)) {
|
||||
const currentIndex = allTypes.value.findIndex((type) =>
|
||||
visIdentifierEquals(type, currentType.value),
|
||||
)
|
||||
@ -365,7 +93,7 @@ const keydownHandler = visualizationBindings.handler({
|
||||
}
|
||||
},
|
||||
toggleFullscreen: () => {
|
||||
if (props.isFocused || focusIsIn(root.value)) {
|
||||
if (props.isFocused || focusIsIn(panelElement.value)) {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
} else {
|
||||
return false
|
||||
@ -382,30 +110,196 @@ const keydownHandler = visualizationBindings.handler({
|
||||
|
||||
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(
|
||||
() => isFullscreen,
|
||||
(f) => {
|
||||
f && nextTick(() => root.value?.focus())
|
||||
f && nextTick(() => panelElement.value?.focus())
|
||||
},
|
||||
)
|
||||
</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>
|
||||
<div ref="root" class="GraphVisualization" tabindex="-1">
|
||||
<Suspense>
|
||||
<template #fallback><LoadingVisualization :data="{}" /></template>
|
||||
<component
|
||||
:is="effectiveVisualization"
|
||||
:data="effectiveVisualizationData"
|
||||
@update:preprocessor="updatePreprocessor_"
|
||||
<div class="GraphVisualization" :style="style">
|
||||
<WithFullscreenMode :fullscreen="isFullscreen" @update:animating="fullscreenAnimating = $event">
|
||||
<div
|
||||
ref="panelElement"
|
||||
class="VisualizationPanel"
|
||||
:class="{
|
||||
fullscreen: isFullscreen || fullscreenAnimating,
|
||||
nonInteractive: isPreview,
|
||||
}"
|
||||
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"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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). */
|
||||
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>
|
||||
|
@ -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, {
|
||||
cancel: onClose,
|
||||
end: onClose,
|
||||
pointerdown: (e, _) => {
|
||||
pointerdown: (e) => {
|
||||
if (
|
||||
targetIsOutside(e, unrefElement(dropdownElement)) &&
|
||||
targetIsOutside(e, unrefElement(activityElement)) &&
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
type RowData,
|
||||
} from '@/components/GraphEditor/widgets/WidgetTableEditor/tableNewArgument'
|
||||
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 { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
|
||||
|
@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { blockTypeToBlockName, type BlockType } from '@/components/MarkdownEditor/formatting'
|
||||
import SelectionDropdown from '@/components/SelectionDropdown.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
|
||||
const blockType = defineModel<BlockType>({ required: true })
|
||||
@ -26,13 +25,15 @@ const blockTypesOrdered: BlockType[] = [
|
||||
'number',
|
||||
'quote',
|
||||
]
|
||||
|
||||
const blockTypeOptions = Object.fromEntries(
|
||||
blockTypesOrdered.map((key) => [
|
||||
key,
|
||||
{ icon: blockTypeIcon[key], label: blockTypeToBlockName[key] },
|
||||
]),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectionDropdown v-model="blockType" :values="blockTypesOrdered">
|
||||
<template #default="{ value }">
|
||||
<SvgIcon :name="blockTypeIcon[value]" />
|
||||
<div class="iconLabel" v-text="blockTypeToBlockName[value]" />
|
||||
</template>
|
||||
</SelectionDropdown>
|
||||
<SelectionDropdown v-model="blockType" :options="blockTypeOptions" labelButton />
|
||||
</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 MenuButton from '@/components/MenuButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { SelectionMenuOption } from '@/components/visualizations/toolbar'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const selected = defineModel<T>({ required: true })
|
||||
const _props = defineProps<{ values: T[] }>()
|
||||
type Key = number | string | symbol
|
||||
const selected = defineModel<Key>({ required: true })
|
||||
const _props = defineProps<{
|
||||
options: Record<Key, SelectionMenuOption>
|
||||
title?: string | undefined
|
||||
labelButton?: boolean
|
||||
alwaysShowArrow?: boolean
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu v-model:open="open">
|
||||
<DropdownMenu v-model:open="open" :title="title" :alwaysShowArrow="alwaysShowArrow">
|
||||
<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 #entries>
|
||||
<MenuButton
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:modelValue="selected === value"
|
||||
@update:modelValue="$event && (selected = value)"
|
||||
v-for="[key, option] in Object.entries(options)"
|
||||
:key="key"
|
||||
:title="option.title"
|
||||
:modelValue="selected === key"
|
||||
@update:modelValue="$event && (selected = key)"
|
||||
@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>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
@ -31,5 +50,11 @@ const open = ref(false)
|
||||
<style scoped>
|
||||
.MenuButton {
|
||||
margin: -4px;
|
||||
justify-content: unset;
|
||||
}
|
||||
|
||||
.iconLabel {
|
||||
margin-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
@ -6,9 +6,9 @@ import type { Icon } from '@/util/iconName'
|
||||
|
||||
const _props = defineProps<{
|
||||
name: Icon | URLString
|
||||
label?: string
|
||||
label?: string | undefined
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
title?: string | undefined
|
||||
}>()
|
||||
</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 SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { URLString } from '@/util/data/urlString'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<MenuButton v-model="toggledOn" class="ToggleIcon" :disabled="props.disabled">
|
||||
<MenuButton v-model="toggledOn" class="ToggleIcon" :disabled="disabled" :title="title">
|
||||
<SvgIcon :name="icon" />
|
||||
<div v-if="props.label" v-text="props.label" />
|
||||
<div v-if="label" v-text="label" />
|
||||
</MenuButton>
|
||||
</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">
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import SelectionDropdown from '@/components/SelectionDropdown.vue'
|
||||
import { useVisualizationStore } from '@/stores/visualization'
|
||||
import { useAutoBlur } from '@/util/autoBlur'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { visIdentifierEquals, type VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||
import { computed } from 'vue'
|
||||
import { type VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||
|
||||
const modelValue = defineModel<VisualizationIdentifier>({ required: true })
|
||||
const props = defineProps<{
|
||||
types: Iterable<VisualizationIdentifier>
|
||||
modelValue: VisualizationIdentifier
|
||||
}>()
|
||||
const emit = defineEmits<{ hide: []; 'update:modelValue': [type: VisualizationIdentifier] }>()
|
||||
|
||||
const visualizationStore = useVisualizationStore()
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
useAutoBlur(rootNode)
|
||||
|
||||
function visIdLabel(id: VisualizationIdentifier) {
|
||||
function visLabel(id: VisualizationIdentifier) {
|
||||
switch (id.module.kind) {
|
||||
case 'Builtin':
|
||||
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
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootNode"
|
||||
<SelectionDropdown
|
||||
:modelValue="visKey(modelValue)"
|
||||
:options="visualizationOptions"
|
||||
title="Visualization Selector"
|
||||
class="VisualizationSelector"
|
||||
@focusout="$event.relatedTarget == null && emit('hide')"
|
||||
>
|
||||
<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>
|
||||
alwaysShowArrow
|
||||
@update:modelValue="modelValue = visualizationByKey.get($event as string)!"
|
||||
/>
|
||||
</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
|
||||
* animated.
|
||||
*
|
||||
* This approach is used when switching visualizations (which replaces the `VisualizationContainer` and its
|
||||
* `WithFullscreenMode` instance).
|
||||
* This approach was previously used when switching visualizations (when each visualization had its own
|
||||
* `VisualizationContainer` and `WithFullscreenMode` instance).
|
||||
*/
|
||||
const savedSize = defineModel<SavedSize | undefined>('savedSize')
|
||||
const emit = defineEmits<{
|
||||
|
@ -8,9 +8,8 @@ import {
|
||||
tsvTableToEnsoExpression,
|
||||
writeClipboard,
|
||||
} from '@/components/GraphEditor/clipboard'
|
||||
import { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue'
|
||||
import { useAutoBlur } from '@/util/autoBlur'
|
||||
import '@ag-grid-community/styles/ag-grid.css'
|
||||
import '@ag-grid-community/styles/ag-theme-alpine.css'
|
||||
import type {
|
||||
CellEditingStartedEvent,
|
||||
CellEditingStoppedEvent,
|
||||
@ -28,7 +27,6 @@ import type {
|
||||
SortChangedEvent,
|
||||
} from 'ag-grid-enterprise'
|
||||
import { type ComponentInstance, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { TextFormatOptions } from '../visualizations/TableVisualization.vue'
|
||||
|
||||
const DEFAULT_ROW_HEIGHT = 22
|
||||
|
||||
@ -204,6 +202,8 @@ const { AgGridVue } = await import('ag-grid-vue3')
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style src="@ag-grid-community/styles/ag-grid.css" />
|
||||
<style src="@ag-grid-community/styles/ag-theme-alpine.css" />
|
||||
<style scoped>
|
||||
.ag-theme-alpine {
|
||||
--ag-grid-size: 3px;
|
@ -88,8 +88,7 @@ declare var deck: typeof import('deck.gl')
|
||||
|
||||
<script setup lang="ts">
|
||||
/// <reference types="@danmarshall/deckgl-typings" />
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import { VisualizationContainer } from '@/util/visualizationBuiltins'
|
||||
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
import type { Deck } from 'deck.gl'
|
||||
import { computed, onUnmounted, ref, watchPostEffect } from 'vue'
|
||||
|
||||
@ -122,6 +121,8 @@ const DEFAULT_MAP_ZOOM = 11
|
||||
const DEFAULT_MAX_MAP_ZOOM = 18
|
||||
const ACCENT_COLOR: Color = [78, 165, 253]
|
||||
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
const dataPoints = ref<LocationWithPosition[]>([])
|
||||
const mapNode = ref<HTMLElement>()
|
||||
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>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :overflow="true">
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
|
@ -41,7 +41,7 @@ interface Bucket {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VisualizationContainer, useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
import { computed, ref, watchPostEffect } from 'vue'
|
||||
|
||||
const d3 = await import('d3')
|
||||
@ -87,20 +87,8 @@ const fill = computed(() =>
|
||||
.domain([0, d3.max(buckets.value, (d) => d.value) ?? 1]),
|
||||
)
|
||||
|
||||
const width = ref(Math.max(config.width ?? 0, config.nodeSize.x))
|
||||
watchPostEffect(() => {
|
||||
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 width = computed(() => config.size.x)
|
||||
const height = computed(() => config.size.y)
|
||||
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))
|
||||
const boxHeight = computed(() => Math.max(0, height.value - MARGIN.top - MARGIN.bottom))
|
||||
|
||||
@ -212,7 +200,6 @@ watchPostEffect(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<div ref="containerNode" class="HeatmapVisualization">
|
||||
<svg :width="width" :height="height">
|
||||
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">
|
||||
@ -222,7 +209,6 @@ watchPostEffect(() => {
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import { useEvent } from '@/composables/events'
|
||||
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
|
||||
import { defineKeybinds } from '@/util/shortcuts'
|
||||
import { VisualizationContainer, useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
|
||||
export const name = 'Histogram'
|
||||
@ -246,20 +245,8 @@ const margin = computed(() => ({
|
||||
bottom: MARGIN + (axis.value?.x?.label ? AXIS_LABEL_HEIGHT : 0),
|
||||
left: MARGIN + (axis.value?.y?.label ? AXIS_LABEL_HEIGHT : 0),
|
||||
}))
|
||||
const width = ref(Math.max(config.width ?? 0, config.nodeSize.x))
|
||||
watchPostEffect(() => {
|
||||
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 width = computed(() => config.size.x)
|
||||
const height = computed(() => config.size.y)
|
||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
||||
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
||||
const xLabelTop = computed(() => boxHeight.value + margin.value.bottom - AXIS_LABEL_HEIGHT / 2)
|
||||
@ -568,14 +555,22 @@ useEvent(document, 'click', endBrushing)
|
||||
useEvent(document, 'auxclick', endBrushing)
|
||||
useEvent(document, 'contextmenu', 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>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<template #toolbar>
|
||||
<SvgButton name="show_all" title="Fit All" @click="zoomToSelected(false)" />
|
||||
<SvgButton name="find" title="Zoom to Selected" @click="zoomToSelected(true)" />
|
||||
</template>
|
||||
<div ref="containerNode" class="HistogramVisualization" @pointerdown.stop>
|
||||
<svg :width="width" :height="height">
|
||||
<rect
|
||||
@ -624,7 +619,6 @@ useEvent(document, 'scroll', endBrushing)
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -10,7 +10,6 @@ interface Data {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VisualizationContainer } from '@/util/visualizationBuiltins'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
@ -23,11 +22,9 @@ const src = computed(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :belowNode="true">
|
||||
<div class="ImageVisualization">
|
||||
<img :src="src" />
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -9,9 +9,10 @@ import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import JsonValueWidget from '@/components/visualizations/JSONVisualization/JsonValueWidget.vue'
|
||||
import { Ast } from '@/util/ast'
|
||||
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()
|
||||
|
||||
@ -19,7 +20,7 @@ type ConstructivePattern = (placeholder: Ast.Owned) => Ast.Owned
|
||||
|
||||
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) {
|
||||
const style = {
|
||||
@ -55,15 +56,13 @@ function createProjection(path: (string | number)[][]) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<div class="JSONVisualization">
|
||||
<JsonValueWidget
|
||||
:data="props.data"
|
||||
:data="data"
|
||||
:class="{ viewonly: !isClickThroughEnabled }"
|
||||
@createProjection="createProjection"
|
||||
/>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -4,7 +4,6 @@ export const inputType = 'Any'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VisualizationContainer } from '@/util/visualizationBuiltins'
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
const props = defineProps<{ data: { name: string; error: Error } }>()
|
||||
@ -13,27 +12,15 @@ watchEffect(() => console.error(props.data.error))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<div class="LoadingErrorVisualization">
|
||||
<div>
|
||||
<span>Could not load visualization '<span v-text="props.data.name"></span>':</span>
|
||||
<span v-text="props.data.error.message"></span>
|
||||
<div>Could not load visualization '<span v-text="props.data.name"></span>':</div>
|
||||
<div v-text="props.data.error.message"></div>
|
||||
</div>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.LoadingErrorVisualization {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.LoadingErrorVisualization > div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
padding: 0 1em;
|
||||
}
|
||||
</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">
|
||||
const _props = defineProps<{ data: unknown }>()
|
||||
import LoadingSpinner from '@/components/shared/LoadingSpinner.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer>
|
||||
<div class="LoadingVisualization">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.LoadingVisualization {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding-top: 30px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
overflow: clip;
|
||||
|
@ -37,7 +37,6 @@ interface Error {
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DEFAULT_THEME, type RGBA, type Theme } from '@/components/visualizations/builtins'
|
||||
import { VisualizationContainer } from '@/util/visualizationBuiltins'
|
||||
import { computed } from 'vue'
|
||||
const sqlFormatter = await import('sql-formatter')
|
||||
|
||||
@ -123,13 +122,11 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<div class="sql-visualization scrollable">
|
||||
<pre v-if="data.error" class="sql" v-text="data.error"></pre>
|
||||
<!-- eslint-disable-next-line vue/no-v-html This is SAFE, beause it is not user input. -->
|
||||
<pre v-else class="sql" v-html="formatted"></pre>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import { useEvent } from '@/composables/events'
|
||||
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { tryNumberToEnso } from '@/util/ast/abstract'
|
||||
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
|
||||
import { VisualizationContainer, defineKeybinds } from '@/util/visualizationBuiltins'
|
||||
import { defineKeybinds } from '@/util/visualizationBuiltins'
|
||||
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
|
||||
export const name = 'Scatter Plot'
|
||||
@ -115,9 +114,6 @@ interface DateObj {
|
||||
const d3 = await import('d3')
|
||||
|
||||
const props = defineProps<{ data: Partial<Data> | number[] }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
@ -260,17 +256,8 @@ const margin = computed(() => {
|
||||
return { top: 10, right: 10, bottom: 35, left: 55 }
|
||||
}
|
||||
})
|
||||
const width = computed(() =>
|
||||
config.fullscreen ?
|
||||
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 width = computed(() => config.size.x)
|
||||
const height = computed(() => config.size.y)
|
||||
|
||||
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))
|
||||
@ -314,8 +301,7 @@ const xTickFormat = computed(() => {
|
||||
watchEffect(() => {
|
||||
const boundsExpression =
|
||||
bounds.value != null ? Ast.Vector.tryBuild(bounds.value, tryNumberToEnso) : undefined
|
||||
emit(
|
||||
'update:preprocessor',
|
||||
config.setPreprocessor(
|
||||
'Standard.Visualization.Scatter_Plot',
|
||||
'process_to_json_text',
|
||||
boundsExpression?.code() ?? 'Nothing',
|
||||
@ -739,24 +725,28 @@ function zoomToSelected(override?: boolean) {
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<template #toolbar>
|
||||
<SvgButton
|
||||
name="select"
|
||||
title="Enable Selection"
|
||||
@click="selectionEnabled = !selectionEnabled"
|
||||
/>
|
||||
<SvgButton name="show_all" title="Fit All" @click.stop="zoomToSelected(false)" />
|
||||
<SvgButton
|
||||
name="zoom"
|
||||
title="Zoom to Selected"
|
||||
:disabled="brushExtent == null"
|
||||
@click.stop="zoomToSelected"
|
||||
/>
|
||||
</template>
|
||||
<div ref="containerNode" class="ScatterplotVisualization">
|
||||
<svg :width="width" :height="height">
|
||||
<g ref="legendNode"></g>
|
||||
@ -791,7 +781,6 @@ useEvent(document, 'keydown', bindings.handler({ zoomToSelected: () => zoomToSel
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -1,12 +1,10 @@
|
||||
<script lang="ts">
|
||||
import icons from '@/assets/icons.svg'
|
||||
import { default as TableVizToolbar, type SortModel } from '@/components/TableVizToolbar.vue'
|
||||
import AgGridTableView from '@/components/widgets/AgGridTableView.vue'
|
||||
import AgGridTableView from '@/components/shared/AgGridTableView.vue'
|
||||
import { SortModel, useTableVizToolbar } from '@/components/visualizations/tableVizToolbar'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import { VisualizationContainer, useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
import '@ag-grid-community/styles/ag-grid.css'
|
||||
import '@ag-grid-community/styles/ag-theme-alpine.css'
|
||||
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
import type {
|
||||
CellClassParams,
|
||||
CellClickedEvent,
|
||||
@ -84,17 +82,14 @@ interface UnknownTable {
|
||||
}
|
||||
|
||||
export enum TextFormatOptions {
|
||||
Partial,
|
||||
On,
|
||||
Partial,
|
||||
Off,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
const INDEX_FIELD_NAME = '#'
|
||||
@ -135,10 +130,6 @@ const columnDefs: Ref<ColDef[]> = ref([])
|
||||
|
||||
const textFormatterSelected = ref<TextFormatOptions>(TextFormatOptions.Partial)
|
||||
|
||||
const updateTextFormat = (option: TextFormatOptions) => {
|
||||
textFormatterSelected.value = option
|
||||
}
|
||||
|
||||
const isRowCountSelectorVisible = computed(() => rowCount.value >= 1000)
|
||||
|
||||
const selectableRowLimits = computed(() => {
|
||||
@ -266,8 +257,7 @@ function formatText(params: ICellRendererParams) {
|
||||
function setRowLimit(newRowLimit: number) {
|
||||
if (newRowLimit !== rowLimit.value) {
|
||||
rowLimit.value = newRowLimit
|
||||
emit(
|
||||
'update:preprocessor',
|
||||
config.setPreprocessor(
|
||||
'Standard.Visualization.Table.Visualization',
|
||||
'prepare_visualization',
|
||||
newRowLimit.toString(),
|
||||
@ -602,19 +592,24 @@ function checkSortAndFilter(e: SortChangedEvent) {
|
||||
onMounted(() => {
|
||||
setRowLimit(1000)
|
||||
})
|
||||
|
||||
// ===============
|
||||
// === Toolbar ===
|
||||
// ===============
|
||||
|
||||
config.setToolbar(
|
||||
useTableVizToolbar({
|
||||
textFormatterSelected,
|
||||
filterModel,
|
||||
sortModel,
|
||||
isDisabled: () => !isCreateNodeEnabled.value,
|
||||
isFilterSortNodeEnabled,
|
||||
createNodes: config.createNodes,
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :belowToolbar="true" :overflow="true" :toolbarOverflow="true">
|
||||
<template #toolbar>
|
||||
<TableVizToolbar
|
||||
:filterModel="filterModel"
|
||||
:sortModel="sortModel"
|
||||
:isDisabled="!isCreateNodeEnabled"
|
||||
:isFilterSortNodeEnabled="isFilterSortNodeEnabled"
|
||||
@changeFormat="(i) => updateTextFormat(i)"
|
||||
/>
|
||||
</template>
|
||||
<div ref="rootNode" class="TableVisualization" @wheel.stop @pointerdown.stop>
|
||||
<div class="table-visualization-status-bar">
|
||||
<select
|
||||
@ -651,7 +646,6 @@ onMounted(() => {
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<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">
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import { useVisualizationConfig, VisualizationContainer } from '@/util/visualizationBuiltins'
|
||||
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export const name = 'Warnings'
|
||||
@ -24,20 +23,19 @@ type Data = string[]
|
||||
const props = defineProps<{ data: Data }>()
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<template #toolbar>
|
||||
<SvgButton
|
||||
name="not_exclamation"
|
||||
data-testid="remove-warnings-button"
|
||||
title="Remove Warnings"
|
||||
: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>
|
||||
@ -45,8 +43,6 @@ const config = useVisualizationConfig()
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.WarningsVisualization {
|
||||
|
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">
|
||||
import LoadingSpinner from '@/components/LoadingSpinner.vue'
|
||||
import LoadingSpinner from '@/components/shared/LoadingSpinner.vue'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { useBackendQuery, useBackendQueryPrefetching } from '@/composables/backend'
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { createContextStore } from '@/providers'
|
||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
||||
import { shallowRef, watch, type WatchSource } from 'vue'
|
||||
|
||||
export { injectFn as injectInteractionHandler, provideFn as provideInteractionHandler }
|
||||
@ -69,12 +68,11 @@ export class InteractionHandler {
|
||||
event: PointerEvent,
|
||||
handlerName: Interaction[HandlerName] extends InteractionEventHandler | undefined ? HandlerName
|
||||
: never,
|
||||
graphNavigator: GraphNavigator,
|
||||
): boolean {
|
||||
if (!this.currentInteraction.value) return false
|
||||
const handler = this.currentInteraction.value[handlerName]
|
||||
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) {
|
||||
event.stopImmediatePropagation()
|
||||
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 {
|
||||
/** 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 { SavedSize } from '@/components/WithFullscreenMode.vue'
|
||||
import { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import { createContextStore } from '@/providers'
|
||||
import type { URLString } from '@/util/data/urlString'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
import { ToValue } from '@/util/reactivity'
|
||||
import { reactive } from 'vue'
|
||||
import type { VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||
|
||||
export interface VisualizationConfig {
|
||||
background?: string
|
||||
/** 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
|
||||
/** The Enso type of the data being visualized. */
|
||||
readonly nodeType: string | undefined
|
||||
readonly isPreview: boolean
|
||||
readonly isFullscreenAllowed: boolean
|
||||
readonly isResizable: boolean
|
||||
isBelowToolbar: boolean
|
||||
width: number
|
||||
height: number
|
||||
nodePosition: Vec2
|
||||
fullscreen: boolean
|
||||
savedSize: SavedSize | undefined
|
||||
hide: () => void
|
||||
updateType: (type: VisualizationIdentifier) => void
|
||||
/** The size of the area available for the visualization to draw its content. */
|
||||
readonly size: Vec2
|
||||
/** Create graph nodes. */
|
||||
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 }
|
||||
|
@ -1,4 +1,3 @@
|
||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
||||
import { InteractionHandler } from '@/providers/interactionHandler'
|
||||
import type { PortId } from '@/providers/portInfo'
|
||||
import { useCurrentEdit, type CurrentEdit } from '@/providers/widgetTree'
|
||||
@ -130,7 +129,6 @@ test.each`
|
||||
'Handling clicks in WidgetEditHandlers case $name',
|
||||
({ widgets, edited, propagatingHandlers, nonPropagatingHandlers, expectedHandlerCalls }) => {
|
||||
const event = new MouseEvent('pointerdown') as PointerEvent
|
||||
const navigator = {} as GraphNavigator
|
||||
const interactionHandler = new InteractionHandler()
|
||||
const widgetTree = proxyRefs(useCurrentEdit())
|
||||
|
||||
@ -144,24 +142,22 @@ test.each`
|
||||
(id) =>
|
||||
propagatingHandlersSet.has(id) ?
|
||||
{
|
||||
pointerdown: vi.fn((e, nav) => {
|
||||
pointerdown: vi.fn((e) => {
|
||||
expect(e).toBe(event)
|
||||
expect(nav).toBe(navigator)
|
||||
return false
|
||||
}),
|
||||
}
|
||||
: nonPropagatingHandlersSet.has(id) ?
|
||||
{
|
||||
pointerdown: vi.fn((e, nav) => {
|
||||
pointerdown: vi.fn((e) => {
|
||||
expect(e).toBe(event)
|
||||
expect(nav).toBe(navigator)
|
||||
}),
|
||||
}
|
||||
: {},
|
||||
widgetTree,
|
||||
)
|
||||
handlers.get(edited)?.handler.start()
|
||||
interactionHandler.handlePointerEvent(event, 'pointerdown', navigator)
|
||||
interactionHandler.handlePointerEvent(event, 'pointerdown')
|
||||
const handlersCalled = new Set<string>()
|
||||
for (const [id, { interaction }] of handlers)
|
||||
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 { injectInteractionHandler } from '@/providers/interactionHandler'
|
||||
import type { PortId } from '@/providers/portInfo'
|
||||
@ -69,10 +68,9 @@ export abstract class WidgetEditHandlerParent {
|
||||
return this.hooks.addItem?.() ?? this.parent?.addItem() ?? false
|
||||
}
|
||||
|
||||
protected pointerdown(event: PointerEvent, navigator: GraphNavigator): boolean | void {
|
||||
if (this.hooks.pointerdown && this.hooks.pointerdown(event, navigator) !== false) return true
|
||||
else
|
||||
return this.activeChild.value ? this.activeChild.value.pointerdown(event, navigator) : false
|
||||
protected pointerdown(event: PointerEvent): boolean | void {
|
||||
if (this.hooks.pointerdown && this.hooks.pointerdown(event) !== false) return true
|
||||
else return this.activeChild.value ? this.activeChild.value.pointerdown(event) : false
|
||||
}
|
||||
|
||||
isActive() {
|
||||
@ -165,8 +163,8 @@ export class WidgetEditHandlerRoot extends WidgetEditHandlerParent implements In
|
||||
this.onEnd()
|
||||
}
|
||||
|
||||
override pointerdown(event: PointerEvent, navigator: GraphNavigator) {
|
||||
return super.pointerdown(event, navigator)
|
||||
override pointerdown(event: PointerEvent) {
|
||||
return super.pointerdown(event)
|
||||
}
|
||||
|
||||
protected override root() {
|
||||
|
@ -118,12 +118,12 @@ function handleClick(
|
||||
const chainedPointerdown = interaction.pointerdown
|
||||
const wrappedInteraction: Interaction = {
|
||||
...interaction,
|
||||
pointerdown: (e: PointerEvent, ...args) => {
|
||||
pointerdown: (e: PointerEvent) => {
|
||||
if (condition(e)) {
|
||||
handler(wrappedInteraction)
|
||||
return false
|
||||
}
|
||||
return chainedPointerdown ? chainedPointerdown(e, ...args) : false
|
||||
return chainedPointerdown ? chainedPointerdown(e) : false
|
||||
},
|
||||
}
|
||||
return wrappedInteraction
|
||||
|
@ -1,3 +1,2 @@
|
||||
export { default as VisualizationContainer } from '@/components/VisualizationContainer.vue'
|
||||
export { useVisualizationConfig } from '@/providers/visualizationConfig'
|
||||
export { defineKeybinds } from '@/util/shortcuts'
|
||||
|
@ -28,14 +28,11 @@ export const inputType = 'Any'
|
||||
\x3c/script>
|
||||
|
||||
\x3cscript setup lang="ts">
|
||||
import { VisualizationContainer } from 'builtins'
|
||||
const props = defineProps<{ data: unknown }>()
|
||||
\x3c/script>
|
||||
|
||||
\x3ctemplate>
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<pre><code class="green-text" v-text="props.data"></code></pre>
|
||||
</VisualizationContainer>
|
||||
\x3c/template>
|
||||
|
||||
\x3cstyle scoped>
|
||||
|
@ -102,42 +102,12 @@ export const setupVue3 = defineSetupVue3(({ app, addWrapper }) => {
|
||||
// Required for visualization stories.
|
||||
provideVisualizationConfig._mock(
|
||||
{
|
||||
fullscreen: false,
|
||||
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() {},
|
||||
size: new Vec2(200, 150),
|
||||
createNodes() {},
|
||||
isFocused: false,
|
||||
isPreview: false,
|
||||
nodePosition: Vec2.Zero,
|
||||
nodeType: 'component',
|
||||
setPreprocessor: () => {},
|
||||
setToolbar: () => {},
|
||||
setToolbarOverlay: () => {},
|
||||
},
|
||||
app,
|
||||
)
|
||||
|
@ -20,7 +20,7 @@
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.VisualizationContainer {
|
||||
.GraphVisualization {
|
||||
z-index: 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
@ -16,7 +16,6 @@ interface Data {
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import { VisualizationContainer } from 'builtins'
|
||||
// Optional: add your own external dependencies.
|
||||
// import dependency from 'https://<js dependency here>'
|
||||
//
|
||||
@ -43,10 +42,8 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer>
|
||||
<!-- <content here> -->
|
||||
{{ props.data }}
|
||||
</VisualizationContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -33,7 +33,14 @@ export default defineConfig({
|
||||
plugins: [
|
||||
wasm(),
|
||||
...(process.env.NODE_ENV === 'development' ? [await VueDevTools()] : []),
|
||||
vue(),
|
||||
vue({
|
||||
customElement: ['**/components/visualizations/**', '**/components/shared/**'],
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag) => tag.startsWith('enso-'),
|
||||
},
|
||||
},
|
||||
}),
|
||||
react({
|
||||
include: fileURLToPath(new URL('../dashboard/**/*.tsx', import.meta.url)),
|
||||
babel: { plugins: ['@babel/plugin-syntax-import-attributes'] },
|
||||
|
Loading…
Reference in New Issue
Block a user