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:
Kaz Wesley 2024-09-23 11:31:26 -07:00 committed by GitHub
parent 3f748fdf5c
commit a8810e19f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1448 additions and 1505 deletions

View File

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

View File

@ -130,37 +130,30 @@ registerAutoBlurHandler()
:deep(.scrollable) {
scrollbar-color: rgba(190 190 190 / 50%) transparent;
}
:deep(.scrollable)::-webkit-scrollbar {
-webkit-appearance: none;
}
:deep(.scrollable)::-webkit-scrollbar-track {
-webkit-box-shadow: none;
}
:deep(.scrollable)::-webkit-scrollbar:vertical {
width: 11px;
}
:deep(.scrollable)::-webkit-scrollbar:horizontal {
height: 11px;
}
:deep(.scrollable)::-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 {
background: rgba(0, 0, 0, 0);
}
:deep(.scrollable)::-webkit-scrollbar-button {
height: 8px;
width: 8px;
&::-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;
}
}
:deep(.draggable) {

View File

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

View File

@ -279,23 +279,13 @@ useEvent(window, 'keydown', (event) => {
(!keyboardBusy() && graphNavigator.keyboardEvents.keydown(event))
})
useEvent(
window,
'pointerdown',
(e) => interaction.handlePointerEvent(e, 'pointerdown', graphNavigator),
{
capture: true,
},
)
useEvent(window, 'pointerdown', (e) => interaction.handlePointerEvent(e, 'pointerdown'), {
capture: true,
})
useEvent(
window,
'pointerup',
(e) => interaction.handlePointerEvent(e, 'pointerup', graphNavigator),
{
capture: true,
},
)
useEvent(window, 'pointerup', (e) => interaction.handlePointerEvent(e, 'pointerup'), {
capture: true,
})
// === Keyboard/Mouse bindings ===

View File

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

View File

@ -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_"
/>
</Suspense>
<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"
/>
</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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 &#183;"
@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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
<div ref="mapNode" class="GeoMapVisualization" @pointerdown.stop @wheel.stop></div>
</template>
<style scoped>

View File

@ -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,17 +200,15 @@ watchPostEffect(() => {
</script>
<template>
<VisualizationContainer :belowToolbar="true">
<div ref="containerNode" class="HeatmapVisualization">
<svg :width="width" :height="height">
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">
<g ref="xAxisNode" class="label label-x" :transform="`translate(0, ${boxHeight})`"></g>
<g ref="yAxisNode" class="label label-y"></g>
<g ref="pointsNode"></g>
</g>
</svg>
</div>
</VisualizationContainer>
<div ref="containerNode" class="HeatmapVisualization">
<svg :width="width" :height="height">
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">
<g ref="xAxisNode" class="label label-x" :transform="`translate(0, ${boxHeight})`"></g>
<g ref="yAxisNode" class="label label-y"></g>
<g ref="pointsNode"></g>
</g>
</svg>
</div>
</template>
<style scoped>

View File

@ -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,63 +555,70 @@ 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
class="color-legend"
:width="COLOR_LEGEND_WIDTH"
:height="boxHeight"
:transform="`translate(${margin.left - COLOR_LEGEND_WIDTH}, ${margin.top})`"
:style="{ fill: 'url(#color-legend-gradient)' }"
/>
<g :transform="`translate(${margin.left}, ${margin.top})`">
<defs>
<clipPath id="histogram-clip-path">
<rect :width="boxWidth" :height="boxHeight"></rect>
</clipPath>
<linearGradient
id="color-legend-gradient"
ref="colorLegendGradientNode"
x1="0%"
y1="100%"
x2="0%"
y2="0%"
></linearGradient>
</defs>
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
<g ref="yAxisNode" class="axis-y"></g>
<text
v-if="axis.x?.label"
class="label label-x"
text-anchor="end"
:x="xLabelLeft"
:y="xLabelTop"
v-text="axis.x?.label"
></text>
<text
v-if="axis.y?.label"
class="label label-y"
text-anchor="end"
:x="yLabelLeft"
:y="yLabelTop"
v-text="axis.y?.label"
></text>
<g ref="plotNode" clip-path="url(#histogram-clip-path)"></g>
<g ref="zoomNode" class="zoom">
<g ref="brushNode" class="brush"></g>
</g>
<div ref="containerNode" class="HistogramVisualization" @pointerdown.stop>
<svg :width="width" :height="height">
<rect
class="color-legend"
:width="COLOR_LEGEND_WIDTH"
:height="boxHeight"
:transform="`translate(${margin.left - COLOR_LEGEND_WIDTH}, ${margin.top})`"
:style="{ fill: 'url(#color-legend-gradient)' }"
/>
<g :transform="`translate(${margin.left}, ${margin.top})`">
<defs>
<clipPath id="histogram-clip-path">
<rect :width="boxWidth" :height="boxHeight"></rect>
</clipPath>
<linearGradient
id="color-legend-gradient"
ref="colorLegendGradientNode"
x1="0%"
y1="100%"
x2="0%"
y2="0%"
></linearGradient>
</defs>
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
<g ref="yAxisNode" class="axis-y"></g>
<text
v-if="axis.x?.label"
class="label label-x"
text-anchor="end"
:x="xLabelLeft"
:y="xLabelTop"
v-text="axis.x?.label"
></text>
<text
v-if="axis.y?.label"
class="label label-y"
text-anchor="end"
:x="yLabelLeft"
:y="yLabelTop"
v-text="axis.y?.label"
></text>
<g ref="plotNode" clip-path="url(#histogram-clip-path)"></g>
<g ref="zoomNode" class="zoom">
<g ref="brushNode" class="brush"></g>
</g>
</svg>
</div>
</VisualizationContainer>
</g>
</svg>
</div>
</template>
<style scoped>

View File

@ -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>
<div class="ImageVisualization">
<img :src="src" />
</div>
</template>
<style scoped>

View File

@ -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"
:class="{ viewonly: !isClickThroughEnabled }"
@createProjection="createProjection"
/>
</div>
</VisualizationContainer>
<div class="JSONVisualization">
<JsonValueWidget
:data="data"
:class="{ viewonly: !isClickThroughEnabled }"
@createProjection="createProjection"
/>
</div>
</template>
<style scoped>

View File

@ -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>
</div>
</VisualizationContainer>
<div class="LoadingErrorVisualization">
<div>Could not load visualization '<span v-text="props.data.name"></span>':</div>
<div v-text="props.data.error.message"></div>
</div>
</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>

View File

@ -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>
<div class="LoadingVisualization">
<LoadingSpinner />
</div>
</template>
<style scoped>
.LoadingVisualization {
display: flex;
height: 100%;
padding-top: 30px;
place-content: center;
place-items: center;
overflow: clip;

View File

@ -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>
<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>
</template>
<style scoped>

View File

@ -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,59 +725,62 @@ 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>
<g :transform="`translate(${margin.left}, ${margin.top})`">
<defs>
<clipPath id="clip">
<rect :width="boxWidth" :height="boxHeight"></rect>
</clipPath>
</defs>
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
<g ref="yAxisNode" class="axis-y"></g>
<text
v-if="data.axis.x.label"
class="label label-x"
text-anchor="end"
:x="xLabelLeft"
:y="xLabelTop"
v-text="data.axis.x.label"
></text>
<text
v-if="showYLabelText"
class="label label-y"
text-anchor="end"
:x="yLabelLeft"
:y="yLabelTop"
v-text="data.axis.y.label"
></text>
<g ref="pointsNode" clip-path="url(#clip)"></g>
<g ref="zoomNode" class="zoom" :width="boxWidth" :height="boxHeight" fill="none">
<g ref="brushNode" class="brush"></g>
</g>
<div ref="containerNode" class="ScatterplotVisualization">
<svg :width="width" :height="height">
<g ref="legendNode"></g>
<g :transform="`translate(${margin.left}, ${margin.top})`">
<defs>
<clipPath id="clip">
<rect :width="boxWidth" :height="boxHeight"></rect>
</clipPath>
</defs>
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
<g ref="yAxisNode" class="axis-y"></g>
<text
v-if="data.axis.x.label"
class="label label-x"
text-anchor="end"
:x="xLabelLeft"
:y="xLabelTop"
v-text="data.axis.x.label"
></text>
<text
v-if="showYLabelText"
class="label label-y"
text-anchor="end"
:x="yLabelLeft"
:y="yLabelTop"
v-text="data.axis.y.label"
></text>
<g ref="pointsNode" clip-path="url(#clip)"></g>
<g ref="zoomNode" class="zoom" :width="boxWidth" :height="boxHeight" fill="none">
<g ref="brushNode" class="brush"></g>
</g>
</svg>
</div>
</VisualizationContainer>
</g>
</svg>
</div>
</template>
<style scoped>

View File

@ -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,56 +592,60 @@ 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
v-if="isRowCountSelectorVisible"
@change="setRowLimit(Number(($event.target as HTMLOptionElement).value))"
>
<option
v-for="limit in selectableRowLimits"
:key="limit"
:value="limit"
v-text="limit"
></option>
</select>
<template v-if="showRowCount">
<span
v-if="isRowCountSelectorVisible && isTruncated"
v-text="` of ${rowCount} rows (Sorting/Filtering disabled).`"
></span>
<span v-else-if="isRowCountSelectorVisible" v-text="' rows.'"></span>
<span v-else-if="rowCount === 1" v-text="'1 row.'"></span>
<span v-else v-text="`${rowCount} rows.`"></span>
</template>
</div>
<!-- TODO[ao]: Suspence in theory is not needed here (the entire visualization is inside
suspense), but for some reason it causes reactivity loop - see https://github.com/enso-org/enso/issues/10782 -->
<Suspense>
<AgGridTableView
class="scrollable grid"
:columnDefs="columnDefs"
:rowData="rowData"
:defaultColDef="defaultColDef"
:textFormatOption="textFormatterSelected"
@sortOrFilterUpdated="(e) => checkSortAndFilter(e)"
/>
</Suspense>
<div ref="rootNode" class="TableVisualization" @wheel.stop @pointerdown.stop>
<div class="table-visualization-status-bar">
<select
v-if="isRowCountSelectorVisible"
@change="setRowLimit(Number(($event.target as HTMLOptionElement).value))"
>
<option
v-for="limit in selectableRowLimits"
:key="limit"
:value="limit"
v-text="limit"
></option>
</select>
<template v-if="showRowCount">
<span
v-if="isRowCountSelectorVisible && isTruncated"
v-text="` of ${rowCount} rows (Sorting/Filtering disabled).`"
></span>
<span v-else-if="isRowCountSelectorVisible" v-text="' rows.'"></span>
<span v-else-if="rowCount === 1" v-text="'1 row.'"></span>
<span v-else v-text="`${rowCount} rows.`"></span>
</template>
</div>
</VisualizationContainer>
<!-- TODO[ao]: Suspence in theory is not needed here (the entire visualization is inside
suspense), but for some reason it causes reactivity loop - see https://github.com/enso-org/enso/issues/10782 -->
<Suspense>
<AgGridTableView
class="scrollable grid"
:columnDefs="columnDefs"
:rowData="rowData"
:defaultColDef="defaultColDef"
:textFormatOption="textFormatterSelected"
@sortOrFilterUpdated="(e) => checkSortAndFilter(e)"
/>
</Suspense>
</div>
</template>
<style scoped>

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

View File

@ -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,28 +23,25 @@ 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>
<li v-for="(warning, index) in props.data" :key="index" v-text="warning"></li>
</ul>
</div>
</template>
</VisualizationContainer>
<div class="WarningsVisualization">
<ul>
<li v-if="props.data.length === 0">There are no warnings.</li>
<li v-for="(warning, index) in props.data" :key="index" v-text="warning"></li>
</ul>
</div>
</template>
<style scoped>

View 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 &#183;',
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] : [])])
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,2 @@
export { default as VisualizationContainer } from '@/components/VisualizationContainer.vue'
export { useVisualizationConfig } from '@/providers/visualizationConfig'
export { defineKeybinds } from '@/util/shortcuts'

View File

@ -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>
<pre><code class="green-text" v-text="props.data"></code></pre>
\x3c/template>
\x3cstyle scoped>

View File

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

View File

@ -20,7 +20,7 @@
cursor: pointer !important;
}
.VisualizationContainer {
.GraphVisualization {
z-index: 0 !important;
min-width: 0 !important;
}

View File

@ -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>
<!-- <content here> -->
{{ props.data }}
</template>
<style scoped>

View File

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