feat: Webpack + Vue + Viewer toolbar (#36)
* feat: Build with webpack * feat: Vue, tailwind + webpack working * feat(vue): Upgraded to vue3 Now using our ui-components, with section box and camera views support * chore: Minor cleanup of logs * fix: ColorBy must only be grouping in order to color always * fix: Bind to groupings to prevent conflicts with tooltipData inputs
This commit is contained in:
+3
-1
@@ -1,8 +1,9 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
requireConfigFile: false,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module'
|
||||
@@ -10,6 +11,7 @@ const config = {
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier'
|
||||
|
||||
@@ -5,3 +5,4 @@ webpack.statistics.dev.html
|
||||
webpack.statistics.prod.html
|
||||
.DS_Store
|
||||
.idea/
|
||||
webpack.statistics.html
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+7
-7
@@ -17,7 +17,7 @@
|
||||
},
|
||||
{
|
||||
"displayName": "Color By",
|
||||
"kind": "GroupingOrMeasure",
|
||||
"kind": "Grouping",
|
||||
"name": "objectColorBy"
|
||||
},
|
||||
{
|
||||
@@ -37,18 +37,18 @@
|
||||
},
|
||||
"select": [
|
||||
{
|
||||
"for": {
|
||||
"in": "stream"
|
||||
"bind": {
|
||||
"to": "stream"
|
||||
}
|
||||
},
|
||||
{
|
||||
"for": {
|
||||
"in": "parentObject"
|
||||
"bind": {
|
||||
"to": "parentObject"
|
||||
}
|
||||
},
|
||||
{
|
||||
"for": {
|
||||
"in": "objectColorBy"
|
||||
"bind": {
|
||||
"to": "objectColorBy"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+6188
-561
File diff suppressed because it is too large
Load Diff
+46
-15
@@ -7,41 +7,72 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"pbiviz": "pbiviz",
|
||||
"start": "pbiviz start",
|
||||
"package": "pbiviz package",
|
||||
"lint": "eslint -c .eslintrc.js --ext .ts src/"
|
||||
"pack": "webpack --config webpack.config.ts",
|
||||
"build": "webpack --config webpack.config.dev.ts",
|
||||
"serve": "webpack-dev-server --config webpack.config.dev.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.21.5",
|
||||
"@babel/runtime-corejs2": "7.21.5",
|
||||
"@speckle/viewer": "^2.13.3",
|
||||
"@babel/runtime": "^7.21.5",
|
||||
"@babel/runtime-corejs3": "^7.21.5",
|
||||
"@headlessui/vue": "^1.7.13",
|
||||
"@heroicons/vue": "^2.0.12",
|
||||
"@speckle/tailwind-theme": "^2.14.1",
|
||||
"@speckle/ui-components": "^2.14.1",
|
||||
"@speckle/viewer": "^2.14.1",
|
||||
"color-interpolate": "^1.0.5",
|
||||
"core-js": "3.30.2",
|
||||
"core-js": "^3.30.2",
|
||||
"lodash": "^4.17.21",
|
||||
"postcss-loader": "^7.3.0",
|
||||
"postcss-preset-env": "^8.4.1",
|
||||
"powerbi-visuals-api": "~5.4.0",
|
||||
"powerbi-visuals-utils-colorutils": "6.0.1",
|
||||
"powerbi-visuals-utils-dataviewutils": "6.0.1",
|
||||
"powerbi-visuals-utils-colorutils": "^6.0.1",
|
||||
"powerbi-visuals-utils-dataviewutils": "^6.0.1",
|
||||
"powerbi-visuals-utils-formattingmodel": "^5.0.0",
|
||||
"powerbi-visuals-utils-interactivityutils": "6.0.2",
|
||||
"powerbi-visuals-utils-tooltiputils": "6.0.1",
|
||||
"regenerator-runtime": "^0.13.11"
|
||||
"powerbi-visuals-utils-interactivityutils": "^6.0.2",
|
||||
"powerbi-visuals-utils-tooltiputils": "^6.0.1",
|
||||
"regenerator-runtime": "^0.13.11",
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.21.8",
|
||||
"@babel/eslint-parser": "^7.21.8",
|
||||
"@babel/preset-env": "^7.21.5",
|
||||
"@types/core-js": "^2.5.5",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@types/lodash": "^4.14.194",
|
||||
"@types/node": "^20.1.7",
|
||||
"@types/regenerator-runtime": "^0.13.1",
|
||||
"@types/three": "^0.150.1",
|
||||
"@types/three": "^0.152.0",
|
||||
"@types/webpack": "^5.28.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||
"@typescript-eslint/parser": "^5.59.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"babel-loader": "^9.1.2",
|
||||
"base64-inline-loader": "^2.0.1",
|
||||
"css-loader": "^6.7.3",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-vue": "^9.13.0",
|
||||
"extra-watch-webpack-plugin": "^1.0.3",
|
||||
"json-loader": "^0.5.7",
|
||||
"mini-css-extract-plugin": "^2.7.5",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss-import": "^15.1.0",
|
||||
"powerbi-visuals-webpack-plugin": "3.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"style-loader": "^3.3.2",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"ts-loader": "^9.4.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.1",
|
||||
"typescript": "^5.0.4",
|
||||
"user-agent-data-types": "^0.3.1"
|
||||
"user-agent-data-types": "^0.3.1",
|
||||
"vue": "^3.3.4",
|
||||
"vue-loader": "^17.1.1",
|
||||
"vue-template-compiler": "^2.7.14",
|
||||
"webpack": "^5.83.0",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
"webpack-cli": "^5.1.1",
|
||||
"webpack-dev-server": "^4.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"author": { "name": "Speckle Systems", "email": "info@speckle.systems" },
|
||||
"assets": { "icon": "assets/logo.png" },
|
||||
"externalJS": [],
|
||||
"style": "style/visual.less",
|
||||
"style": "style/visual.css",
|
||||
"capabilities": "capabilities.json",
|
||||
"dependencies": null,
|
||||
"stringResources": []
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'postcss-nesting': {}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import HomeView from './views/HomeView.vue'
|
||||
import ViewerView from './views/ViewerView.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { storeKey } from 'src/injectionKeys'
|
||||
|
||||
let store = useStore(storeKey)
|
||||
let status = computed(() => {
|
||||
return store.state.status
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ViewerView v-if="status == 'valid'" />
|
||||
<HomeView v-else />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { VideoCameraIcon, CubeIcon } from '@heroicons/vue/24/solid'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
|
||||
import { CanonicalView, SpeckleView } from '@speckle/viewer'
|
||||
import ButtonToggle from 'src/components/controls/ButtonToggle.vue'
|
||||
import ButtonGroup from 'src/components/controls/ButtonGroup.vue'
|
||||
|
||||
const emits = defineEmits(['update:sectionBox', 'view-clicked'])
|
||||
const props = withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
|
||||
sectionBox: false,
|
||||
views: () => []
|
||||
})
|
||||
|
||||
const canonicalViews = [
|
||||
{ name: 'Top' },
|
||||
{ name: 'Front' },
|
||||
{ name: 'Left' },
|
||||
{ name: 'Back' },
|
||||
{ name: 'Right' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonGroup>
|
||||
<Menu as="div" class="relative z-30">
|
||||
<MenuButton v-slot="{ open }" as="template">
|
||||
<ButtonToggle flat secondary :active="open">
|
||||
<VideoCameraIcon class="h-5 w-5" />
|
||||
</ButtonToggle>
|
||||
</MenuButton>
|
||||
<Transition
|
||||
enter-active-class="transform ease-out duration-300 transition"
|
||||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute w-60 left-2 -translate-y-8 bottom-2 bg-foundation max-h-64 simple-scrollbar overflow-y-auto outline outline-2 outline-primary-muted rounded-lg shadow-lg overflow-hidden flex flex-col"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="view in canonicalViews"
|
||||
:key="view.name"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
>
|
||||
<button
|
||||
:class="{
|
||||
'bg-primary text-foreground-on-primary': active,
|
||||
'text-foreground': !active,
|
||||
'text-sm py-2 transition': true
|
||||
}"
|
||||
@click="$emit('view-clicked', view.name.toLowerCase() as CanonicalView)"
|
||||
>
|
||||
{{ view.name }}
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-for="view in views" :key="view.name" v-slot="{ active }" as="template">
|
||||
<button
|
||||
:class="{
|
||||
'bg-primary text-foreground-on-primary': active,
|
||||
'text-foreground': !active,
|
||||
'text-sm py-2 transition': true
|
||||
}"
|
||||
@click="$emit('view-clicked', view)"
|
||||
>
|
||||
{{ view.view.name ?? view.name }}
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<ButtonToggle
|
||||
flat
|
||||
secondary
|
||||
:active="sectionBox"
|
||||
@click="$emit('update:sectionBox', !sectionBox)"
|
||||
>
|
||||
<CubeIcon class="h-5 w-5" />
|
||||
</ButtonToggle>
|
||||
</ButtonGroup>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script async setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, onMounted, Ref, ref, watch, watchEffect } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import ViewerControls from 'src/components/ViewerControls.vue'
|
||||
import { SpeckleView } from '@speckle/viewer'
|
||||
import { CommonLoadingBar } from '@speckle/ui-components'
|
||||
import ViewerHandler from 'src/handlers/viewerHandler'
|
||||
import { useClickDragged } from 'src/composables/useClickDragged'
|
||||
import { isMultiSelect } from 'src/utils/isMultiSelect'
|
||||
import { selectionHandlerKey, storeKey, tooltipHandlerKey } from 'src/injectionKeys'
|
||||
import { SpeckleDataInput } from 'src/types'
|
||||
import { debounce, throttle } from 'lodash'
|
||||
|
||||
const selectionHandler = inject(selectionHandlerKey)
|
||||
const tooltipHandler = inject(tooltipHandlerKey)
|
||||
const store = useStore(storeKey)
|
||||
const { dragged } = useClickDragged()
|
||||
|
||||
let viewerHandler: ViewerHandler = null
|
||||
let ac = new AbortController()
|
||||
|
||||
const container = ref<HTMLElement>()
|
||||
let bboxActive = ref(false)
|
||||
let views: Ref<SpeckleView[]> = ref([])
|
||||
let updateTask: Ref<Promise<void>> = ref(null)
|
||||
let setupTask: Promise<void> = null
|
||||
|
||||
const isLoading = computed(() => updateTask.value != null)
|
||||
const input = computed(() => store.state.input)
|
||||
|
||||
const onCameraMoved = throttle((_) => {
|
||||
const pos = tooltipHandler.currentTooltip?.worldPos
|
||||
if (!pos) return
|
||||
const screenPos = viewerHandler.getScreenPosition(pos)
|
||||
tooltipHandler.move(screenPos)
|
||||
}, 50)
|
||||
|
||||
onMounted(() => {
|
||||
viewerHandler = new ViewerHandler(container.value)
|
||||
setupTask = viewerHandler
|
||||
.init()
|
||||
.then(() => viewerHandler.addCameraUpdateEventListener(onCameraMoved))
|
||||
.finally(() => {
|
||||
if (input.value) cancelAndHandleDataUpdate()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
await viewerHandler.dispose()
|
||||
viewerHandler = null
|
||||
})
|
||||
|
||||
const debounceUpdate = debounce(cancelAndHandleDataUpdate, 500)
|
||||
watch(input, debounceUpdate)
|
||||
|
||||
watchEffect(() => {
|
||||
if (!isLoading.value) viewerHandler?.setSectionBox(bboxActive.value, input.value.objectIds)
|
||||
})
|
||||
|
||||
function handleDataUpdate(input: Ref<SpeckleDataInput>, signal: AbortSignal) {
|
||||
updateTask.value = setupTask
|
||||
.then(async () => {
|
||||
signal.throwIfAborted()
|
||||
// Clear previous selection
|
||||
await viewerHandler.selectObjects(null)
|
||||
|
||||
// Load
|
||||
await viewerHandler.loadObjectsWithAutoUnload(
|
||||
input.value.objectsToLoad,
|
||||
console.log,
|
||||
console.error,
|
||||
signal
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
// Color
|
||||
await viewerHandler.colorObjectsByGroup(input.value.colorByIds)
|
||||
|
||||
// Select
|
||||
await viewerHandler.unIsolateObjects()
|
||||
if (input.value.selectedIds.length == 0)
|
||||
await viewerHandler.isolateObjects(input.value.objectIds, true)
|
||||
else await viewerHandler.isolateObjects(input.value.selectedIds, true)
|
||||
|
||||
signal.throwIfAborted()
|
||||
// Update available views
|
||||
views.value = viewerHandler.getViews()
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
console.log('Loading operation was aborted', e)
|
||||
})
|
||||
.finally(() => {
|
||||
updateTask.value = null
|
||||
})
|
||||
}
|
||||
|
||||
async function cancelAndHandleDataUpdate() {
|
||||
console.log('Input has changed', input.value)
|
||||
if (updateTask.value) {
|
||||
ac.abort()
|
||||
console.log('Cancelling previous load job')
|
||||
await updateTask
|
||||
ac = new AbortController()
|
||||
}
|
||||
const signal = ac.signal
|
||||
handleDataUpdate(input, signal)
|
||||
}
|
||||
|
||||
async function onCanvasClick(ev: MouseEvent) {
|
||||
if (dragged.value) return
|
||||
const intersectResult = await viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
|
||||
const multi = isMultiSelect(ev)
|
||||
const hit = intersectResult?.hit
|
||||
if (hit) {
|
||||
const id = hit.object.id as string
|
||||
if (multi || !selectionHandler.isSelected(id)) await selectionHandler.select(id, multi)
|
||||
tooltipHandler.show(hit, { x: ev.clientX, y: ev.clientY })
|
||||
const selection = selectionHandler.getCurrentSelection()
|
||||
const ids = selection.map((s) => s.id)
|
||||
await viewerHandler.selectObjects(ids)
|
||||
} else {
|
||||
tooltipHandler.hide()
|
||||
if (!multi) {
|
||||
selectionHandler.clear()
|
||||
await viewerHandler.selectObjects(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onCanvasAuxClick(ev: MouseEvent) {
|
||||
if (ev.button != 2 || dragged.value) return
|
||||
const intersectResult = await viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
|
||||
await selectionHandler.showContextMenu(ev, intersectResult?.hit)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<div
|
||||
ref="container"
|
||||
class="fixed h-full w-full z-0"
|
||||
@click="onCanvasClick"
|
||||
@auxclick="onCanvasAuxClick"
|
||||
/>
|
||||
<div class="z-30 w-1/2 px-10">
|
||||
<common-loading-bar :loading="isLoading" />
|
||||
</div>
|
||||
<viewer-controls
|
||||
v-if="!isLoading"
|
||||
v-model:section-box="bboxActive"
|
||||
:views="views"
|
||||
class="fixed bottom-6"
|
||||
@view-clicked="(view) => viewerHandler.setView(view)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<button
|
||||
class="bg-foundation text-foreground shadow-md rounded-lg h-10 flex justify-center space-x-2 px-1"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<button
|
||||
:class="`transition rounded-lg w-10 h-10 flex items-center justify-center ${shadowClasses} ${colorClasses} active:scale-[0.9] outline-none`"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
active?: boolean
|
||||
flat?: boolean
|
||||
secondary?: boolean
|
||||
}>()
|
||||
|
||||
const shadowClasses = computed(() => (props.flat ? '' : 'shadow-md'))
|
||||
|
||||
const colorClasses = computed(() => {
|
||||
const parts = []
|
||||
if (props.active) {
|
||||
if (props.secondary) parts.push('bg-foundation text-primary')
|
||||
else parts.push('bg-primary text-foreground-on-primary')
|
||||
} else {
|
||||
parts.push('bg-foundation text-foreground')
|
||||
}
|
||||
return parts.join(' ')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ref, onMounted, onUnmounted, Ref } from 'vue'
|
||||
|
||||
// by convention, composable function names start with "use"
|
||||
export function useClickDragged(threshold = 1) {
|
||||
// state encapsulated and managed by the composable
|
||||
const dragged = ref(false)
|
||||
const distance = ref(0)
|
||||
const start: Ref<{ x: number; y: number }> = ref(null)
|
||||
const current: Ref<{ x: number; y: number }> = ref(null)
|
||||
function onPointerMove(ev) {
|
||||
distance.value = Math.sqrt(
|
||||
Math.pow(ev.x - start.value.x, 2) * Math.pow(ev.y - start.value.y, 2)
|
||||
)
|
||||
if (distance.value > threshold) {
|
||||
dragged.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(ev) {
|
||||
dragged.value = false
|
||||
start.value = { x: ev.x, y: ev.y }
|
||||
current.value = start.value
|
||||
distance.value = 0
|
||||
document.addEventListener('pointermove', onPointerMove)
|
||||
}
|
||||
|
||||
function onPointerUp(_) {
|
||||
if (dragged.value === false) reset()
|
||||
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
}
|
||||
function reset() {
|
||||
start.value = null
|
||||
current.value = null
|
||||
distance.value = 0
|
||||
}
|
||||
|
||||
// a composable can also hook into its owner component's
|
||||
// lifecycle to setup and teardown side effects.
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onPointerDown)
|
||||
document.addEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('pointerdown', onPointerDown)
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
|
||||
// expose managed state as return value
|
||||
return { dragged, distance }
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
export default class LandingPageHandler {
|
||||
public enabled = false
|
||||
public landingPage: Element = null
|
||||
public target: HTMLElement
|
||||
|
||||
constructor(target: HTMLElement) {
|
||||
this.target = target
|
||||
this.landingPage = createLandingPageElement(this.target)
|
||||
}
|
||||
|
||||
public show() {
|
||||
console.log('Show landing page')
|
||||
if (!this.enabled) {
|
||||
this.target.appendChild(this.landingPage)
|
||||
this.enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
public hide() {
|
||||
console.log('Hide landing page')
|
||||
if (this.enabled) {
|
||||
this.target.removeChild(this.landingPage)
|
||||
this.enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createLandingPageElement(parent: HTMLElement): Element {
|
||||
const container = parent.appendChild(document.createElement('div'));
|
||||
container.classList.add('speckle-landing')
|
||||
|
||||
const img = document.createElement('div');
|
||||
img.classList.add('speckle-logo')
|
||||
container.appendChild(img)
|
||||
|
||||
const subtext = document.createElement('p');
|
||||
subtext.classList.add('heading')
|
||||
subtext.textContent = 'PowerBI 3D Viewer'
|
||||
container.appendChild(subtext)
|
||||
|
||||
const tipContainer = document.createElement('div');
|
||||
tipContainer.classList.add('tip-container')
|
||||
|
||||
const tip = document.createElement('p');
|
||||
tip.textContent = 'Getting started 💡'
|
||||
tip.classList.add('tip')
|
||||
tipContainer.appendChild(tip)
|
||||
|
||||
const instructions = document.createElement('p');
|
||||
instructions.classList.add('instructions')
|
||||
instructions.textContent = 'Please connect the Stream ID and Object ID fields.'
|
||||
tipContainer.appendChild(instructions)
|
||||
|
||||
const instructions2 = document.createElement('p')
|
||||
instructions2.classList.add('instructions')
|
||||
instructions2.textContent =
|
||||
"Optionally, connect the 'Object Data' field to color the objects by a value"
|
||||
tipContainer.appendChild(instructions2)
|
||||
|
||||
const instructions3 = document.createElement('p')
|
||||
instructions3.classList.add('instructions')
|
||||
instructions3.classList.add('docs')
|
||||
instructions3.innerHTML = 'For more info, check our docs page <b>https://speckle.guide</b>'
|
||||
tipContainer.appendChild(instructions3)
|
||||
|
||||
container.appendChild(tipContainer)
|
||||
return container
|
||||
}
|
||||
@@ -4,8 +4,6 @@ export default class SelectionHandler {
|
||||
private selectionManager: powerbi.extensibility.ISelectionManager
|
||||
private host: powerbi.extensibility.visual.IVisualHost
|
||||
|
||||
public PingScreenPosition: (worldPosition) => { x: number; y: number }
|
||||
|
||||
public constructor(host: powerbi.extensibility.visual.IVisualHost) {
|
||||
this.host = host
|
||||
this.selectionManager = this.host.createSelectionManager()
|
||||
|
||||
@@ -8,8 +8,6 @@ export default class TooltipHandler {
|
||||
private tooltipService: ITooltipService
|
||||
public currentTooltip: SpeckleTooltip = null
|
||||
|
||||
public PingScreenPosition: (worldPosition) => { x: number; y: number } = null
|
||||
|
||||
constructor(tooltipService) {
|
||||
this.tooltipService = tooltipService
|
||||
this.data = new Map<string, IViewerTooltip>()
|
||||
@@ -46,9 +44,8 @@ export default class TooltipHandler {
|
||||
this.currentTooltip = null
|
||||
}
|
||||
|
||||
public move() {
|
||||
public move(pos: { x: number; y: number }) {
|
||||
if (!this.currentTooltip) return
|
||||
const pos = this.PingScreenPosition(this.currentTooltip.worldPos)
|
||||
this.currentTooltip.tooltip.coordinates = [pos.x, pos.y]
|
||||
this.tooltipService.move(this.currentTooltip.tooltip)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
FilteringState,
|
||||
Viewer,
|
||||
IntersectionQuery,
|
||||
DefaultViewerParams
|
||||
DefaultViewerParams,
|
||||
Box3,
|
||||
SpeckleView
|
||||
} from '@speckle/viewer'
|
||||
import { createViewerContainerDiv, pickViewableHit, projectToScreen } from '../utils/viewerUtils'
|
||||
import { pickViewableHit, projectToScreen } from '../utils/viewerUtils'
|
||||
import { SpeckleVisualSettings } from '../settings'
|
||||
import { SettingsChangedType, Tracker } from '../utils/mixpanel'
|
||||
import _ from 'lodash'
|
||||
@@ -19,19 +21,46 @@ export default class ViewerHandler {
|
||||
authToken: null,
|
||||
batchSize: 25
|
||||
}
|
||||
private currentSectionBox: Box3 = null
|
||||
|
||||
public OnCameraUpdate: () => void
|
||||
public getViews() {
|
||||
return this.viewer.getViews()
|
||||
}
|
||||
|
||||
public setView(view: SpeckleView | CanonicalView) {
|
||||
this.viewer.setView(view)
|
||||
if (typeof view === 'string') this.viewer.zoom([...this.loadedObjectsCache], 10, false)
|
||||
}
|
||||
|
||||
public setSectionBox(active: boolean, objectIds: string[]) {
|
||||
if (active) {
|
||||
if (this.currentSectionBox === null) {
|
||||
const bbox = this.viewer.getSectionBoxFromObjects(objectIds)
|
||||
this.viewer.setSectionBox(bbox)
|
||||
this.currentSectionBox = bbox
|
||||
} else {
|
||||
const bbox = this.viewer.getCurrentSectionBox()
|
||||
if (bbox) this.currentSectionBox = bbox
|
||||
}
|
||||
this.viewer.sectionBoxOn()
|
||||
} else {
|
||||
this.viewer.sectionBoxOff()
|
||||
}
|
||||
this.viewer.requestRender()
|
||||
}
|
||||
|
||||
public addCameraUpdateEventListener(listener: (ev) => void) {
|
||||
this.viewer.cameraHandler.controls.addEventListener('update', listener)
|
||||
}
|
||||
public removeCameraUpdateEventListener(listener: (ev) => void) {
|
||||
this.viewer.cameraHandler.controls.removeEventListener('update', listener)
|
||||
}
|
||||
|
||||
public constructor(parent: HTMLElement) {
|
||||
this.parent = parent
|
||||
}
|
||||
|
||||
private onCameraUpdate(args) {
|
||||
if (this.OnCameraUpdate) this.OnCameraUpdate()
|
||||
}
|
||||
|
||||
public changeSettings(oldSettings: SpeckleVisualSettings, newSettings: SpeckleVisualSettings) {
|
||||
console.log('Changing settings in viewer')
|
||||
if (oldSettings.camera.orthoMode != newSettings.camera.orthoMode) {
|
||||
Tracker.settingsChanged(SettingsChangedType.OrthoMode)
|
||||
if (newSettings.camera.orthoMode) this.viewer.cameraHandler?.setOrthoCameraOn()
|
||||
@@ -46,20 +75,12 @@ export default class ViewerHandler {
|
||||
|
||||
public async init() {
|
||||
if (this.viewer) return
|
||||
console.log('Initializing viewer')
|
||||
const container = createViewerContainerDiv(this.parent)
|
||||
const viewerSettings = DefaultViewerParams
|
||||
viewerSettings.showStats = false
|
||||
viewerSettings.verbose = false
|
||||
const viewer = new Viewer(container, viewerSettings)
|
||||
|
||||
const viewer = new Viewer(this.parent, viewerSettings)
|
||||
await viewer.init()
|
||||
|
||||
// Setup any events here (progress, load-complete...)
|
||||
viewer.cameraHandler.controls.addEventListener('update', this.onCameraUpdate.bind(this))
|
||||
|
||||
this.viewer = viewer
|
||||
console.log('Viewer initialized')
|
||||
}
|
||||
|
||||
public async unloadObjects(
|
||||
@@ -67,7 +88,6 @@ export default class ViewerHandler {
|
||||
signal?: AbortSignal,
|
||||
onObjectUnloaded?: (url: string) => void
|
||||
) {
|
||||
console.log('Unloading objects')
|
||||
for (const url of objects) {
|
||||
if (signal?.aborted) return
|
||||
await this.viewer
|
||||
@@ -96,7 +116,6 @@ export default class ViewerHandler {
|
||||
onError: (url: string, error: Error) => void,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
console.groupCollapsed('Loading objects')
|
||||
try {
|
||||
let index = 0
|
||||
let promises = []
|
||||
@@ -125,8 +144,6 @@ export default class ViewerHandler {
|
||||
await Promise.all(promises)
|
||||
} catch (error) {
|
||||
throw new Error(`Load objects failed: ${error}`)
|
||||
} finally {
|
||||
console.groupEnd()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { InjectionKey } from 'vue'
|
||||
import SelectionHandler from 'src/handlers/selectionHandler'
|
||||
import TooltipHandler from 'src/handlers/tooltipHandler'
|
||||
import { Store } from 'vuex'
|
||||
import { SpeckleVisualState } from 'src/store'
|
||||
|
||||
export const selectionHandlerKey: InjectionKey<SelectionHandler> = Symbol()
|
||||
export const tooltipHandlerKey: InjectionKey<TooltipHandler> = Symbol()
|
||||
export const hostKey: InjectionKey<powerbi.extensibility.visual.IVisualHost> = Symbol()
|
||||
export const storeKey: InjectionKey<Store<SpeckleVisualState>> = Symbol()
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createStore } from 'vuex'
|
||||
import { SpeckleDataInput } from 'src/types'
|
||||
export type InputState = 'valid' | 'incomplete' | 'invalid'
|
||||
|
||||
export interface SpeckleVisualState {
|
||||
input?: SpeckleDataInput
|
||||
status: InputState
|
||||
}
|
||||
|
||||
// Create a new store instance.
|
||||
export const store = createStore<SpeckleVisualState>({
|
||||
state() {
|
||||
return {
|
||||
input: null,
|
||||
status: 'incomplete'
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
setInput(state, input?: SpeckleDataInput) {
|
||||
state.input = input
|
||||
},
|
||||
setStatus(state, status: InputState) {
|
||||
state.status = status ?? 'invalid'
|
||||
},
|
||||
clearInput(state) {
|
||||
state.input = null
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
update(context, status: InputState, input?: SpeckleDataInput) {
|
||||
context.commit('setInput', input)
|
||||
context.commit('setStatus', status)
|
||||
}
|
||||
}
|
||||
})
|
||||
+1
-1
@@ -8,7 +8,7 @@ export interface IViewerTooltip {
|
||||
data: IViewerTooltipData[]
|
||||
}
|
||||
|
||||
export type SpeckleDataInput = {
|
||||
export interface SpeckleDataInput {
|
||||
objectsToLoad: string[]
|
||||
objectIds: string[]
|
||||
selectedIds: string[]
|
||||
|
||||
@@ -42,12 +42,15 @@ function processObjectValues(
|
||||
let shouldColor = true,
|
||||
shouldSelect = false
|
||||
|
||||
if (objectIdChild.values)
|
||||
Object.keys(objectIdChild.values).forEach((key) => {
|
||||
const value: powerbi.DataViewMatrixNodeValue = objectIdChild.values[key]
|
||||
const k: unknown = key
|
||||
const colInfo = matrixView.valueSources[k as number]
|
||||
const highLightActive = value.highlight !== undefined
|
||||
if (highLightActive) shouldColor = false
|
||||
if (highLightActive) {
|
||||
shouldColor = false
|
||||
}
|
||||
const isHighlighted = value.highlight !== null
|
||||
|
||||
if (highLightActive && isHighlighted) {
|
||||
@@ -76,9 +79,8 @@ function processObjectNode(
|
||||
.createSelectionId()
|
||||
|
||||
// Create value records for the tooltips
|
||||
if (objectIdChild.values) {
|
||||
var objectValues = processObjectValues(objectIdChild, matrixView)
|
||||
}
|
||||
|
||||
return { id: objId, selectionId: nodeSelection, ...objectValues }
|
||||
}
|
||||
|
||||
@@ -93,7 +95,6 @@ function processObjectIdLevel(
|
||||
}
|
||||
|
||||
var previousPalette = null
|
||||
var previousPaletteKey = null
|
||||
export function processMatrixView(
|
||||
matrixView: powerbi.DataViewMatrix,
|
||||
host: powerbi.extensibility.visual.IVisualHost,
|
||||
@@ -124,6 +125,7 @@ export function processMatrixView(
|
||||
})
|
||||
})
|
||||
} else {
|
||||
console.log('🖌️✅ HAS COLOR FILTER')
|
||||
if (previousPalette) host.colorPalette['colorPalette'] = previousPalette
|
||||
parentObjectIdChild.children?.forEach((colorByChild) => {
|
||||
const color = host.colorPalette.getColor(colorByChild.value as string)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { FormButton } from '@speckle/ui-components'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
id="speckle-home-view"
|
||||
class="flex flex-col justify-center items-center h-full w-full bg-primary text-center text-foundation"
|
||||
>
|
||||
<div class="flex justify-center items-center">
|
||||
<img src="../../assets/logo-white.png" alt="Logo" class="w-1/2" />
|
||||
</div>
|
||||
<p class="heading">Speckle PowerBI 3D Visual</p>
|
||||
<p class="">Some subtext here...</p>
|
||||
<div class="flex justify-center gap-2">
|
||||
<FormButton color="invert">Help</FormButton>
|
||||
<FormButton color="invert">Getting started</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import ViewerWrapper from 'src/components/ViewerWrapper.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<viewer-wrapper id="speckle-3d-view" class="h-full w-full"></viewer-wrapper>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
+31
-148
@@ -1,18 +1,18 @@
|
||||
import 'core-js/stable'
|
||||
import 'regenerator-runtime/runtime'
|
||||
import './../style/visual.less'
|
||||
|
||||
import '../style/visual.css'
|
||||
import * as _ from 'lodash'
|
||||
import { FormattingSettingsService } from 'powerbi-visuals-utils-formattingmodel'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { store } from 'src/store'
|
||||
import { hostKey, selectionHandlerKey, tooltipHandlerKey, storeKey } from 'src/injectionKeys'
|
||||
|
||||
import { Tracker } from './utils/mixpanel'
|
||||
import { SpeckleDataInput } from './types'
|
||||
import { processMatrixView, validateMatrixView } from './utils/matrixViewUtils'
|
||||
import { SpeckleVisualSettings } from './settings'
|
||||
import { SpeckleVisualSettingsModel } from './settings/visualSettingsModel'
|
||||
|
||||
import ViewerHandler from './handlers/viewerHandler'
|
||||
import LandingPageHandler from './handlers/landingPageHandler'
|
||||
import TooltipHandler from './handlers/tooltipHandler'
|
||||
import SelectionHandler from './handlers/selectionHandler'
|
||||
|
||||
@@ -20,70 +20,42 @@ import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructor
|
||||
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions
|
||||
import IVisual = powerbi.extensibility.visual.IVisual
|
||||
import ITooltipService = powerbi.extensibility.ITooltipService
|
||||
import { isMultiSelect } from './utils/isMultiSelect'
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export class Visual implements IVisual {
|
||||
private readonly target: HTMLElement
|
||||
private readonly host: powerbi.extensibility.visual.IVisualHost
|
||||
private readonly viewerHandler: ViewerHandler
|
||||
|
||||
private selectionHandler: SelectionHandler
|
||||
private landingPageHandler: LandingPageHandler
|
||||
private tooltipHandler: TooltipHandler
|
||||
|
||||
private formattingSettings: SpeckleVisualSettingsModel
|
||||
private formattingSettingsService: FormattingSettingsService
|
||||
private updateTask: Promise<void>
|
||||
private ac = new AbortController()
|
||||
private moved = false
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
public constructor(options: VisualConstructorOptions) {
|
||||
Tracker.loaded()
|
||||
this.host = options.host
|
||||
this.target = options.element
|
||||
|
||||
this.formattingSettingsService = new FormattingSettingsService()
|
||||
|
||||
console.log('🚀 Init handlers')
|
||||
|
||||
this.selectionHandler = new SelectionHandler(this.host)
|
||||
this.landingPageHandler = new LandingPageHandler(this.target)
|
||||
this.viewerHandler = new ViewerHandler(this.target)
|
||||
this.tooltipHandler = new TooltipHandler(this.host.tooltipService as ITooltipService)
|
||||
|
||||
console.log('🚀 Setup handler events')
|
||||
console.log('🚀 Init Vue App')
|
||||
createApp(App)
|
||||
.use(store, storeKey)
|
||||
.provide(selectionHandlerKey, this.selectionHandler)
|
||||
.provide(tooltipHandlerKey, this.tooltipHandler)
|
||||
.provide(hostKey, options.host)
|
||||
.mount(options.element)
|
||||
|
||||
this.target.addEventListener('pointerdown', this.onPointerDown)
|
||||
this.target.addEventListener('pointerup', this.onPointerUp)
|
||||
|
||||
this.target.addEventListener('click', this.onClick)
|
||||
this.target.addEventListener('auxclick', this.onAuxClick)
|
||||
|
||||
this.viewerHandler.OnCameraUpdate = _.throttle((args) => {
|
||||
this.tooltipHandler.move()
|
||||
}, 1000.0 / 60.0).bind(this)
|
||||
|
||||
this.tooltipHandler.PingScreenPosition = this.viewerHandler.getScreenPosition.bind(
|
||||
this.viewerHandler
|
||||
)
|
||||
this.selectionHandler.PingScreenPosition = this.viewerHandler.getScreenPosition.bind(
|
||||
this.viewerHandler
|
||||
)
|
||||
|
||||
SpeckleVisualSettings.OnSettingsChanged = (oldSettings, newSettings) => {
|
||||
this.viewerHandler.changeSettings(oldSettings, newSettings)
|
||||
}
|
||||
|
||||
//Show landing Page by default
|
||||
this.landingPageHandler.show()
|
||||
// SpeckleVisualSettings.OnSettingsChanged = (oldSettings, newSettings) => {
|
||||
// this.viewerHandler.changeSettings(oldSettings, newSettings)
|
||||
// }
|
||||
}
|
||||
|
||||
private async clear() {
|
||||
this.ac.abort()
|
||||
await this.updateTask
|
||||
await this.viewerHandler.clear()
|
||||
this.selectionHandler.clear()
|
||||
this.ac = new AbortController()
|
||||
}
|
||||
|
||||
public update(options: VisualUpdateOptions) {
|
||||
@@ -91,13 +63,14 @@ export class Visual implements IVisual {
|
||||
SpeckleVisualSettingsModel,
|
||||
options.dataViews
|
||||
)
|
||||
SpeckleVisualSettings.handleSettingsModelUpdate(this.formattingSettings)
|
||||
//SpeckleVisualSettings.handleSettingsModelUpdate(this.formattingSettings)
|
||||
|
||||
let validationResult: { hasColorFilter: boolean; view: powerbi.DataViewMatrix } = null
|
||||
try {
|
||||
console.log('🔍 Validating input...', options)
|
||||
var validationResult = validateMatrixView(options)
|
||||
validationResult = validateMatrixView(options)
|
||||
console.log('✅Input valid', validationResult)
|
||||
this.landingPageHandler.hide()
|
||||
store.commit('setStatus', 'valid')
|
||||
} catch (e) {
|
||||
console.log('❌Input not valid:', (e as Error).message)
|
||||
this.host.displayWarningIcon(
|
||||
@@ -105,12 +78,10 @@ export class Visual implements IVisual {
|
||||
`"Stream URL" and "Object ID" data inputs are mandatory`
|
||||
)
|
||||
console.warn(`Incomplete data input. "Stream URL" and "Object ID" data inputs are mandatory`)
|
||||
this.clear()
|
||||
this.landingPageHandler.show()
|
||||
store.commit('setStatus', 'incomplete')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch (options.type) {
|
||||
case powerbi.VisualUpdateType.Resize:
|
||||
case powerbi.VisualUpdateType.ResizeEnd:
|
||||
@@ -119,39 +90,21 @@ export class Visual implements IVisual {
|
||||
case powerbi.VisualUpdateType.Resize + powerbi.VisualUpdateType.ResizeEnd:
|
||||
return
|
||||
default:
|
||||
var input = processMatrixView(
|
||||
// @ts-ignore
|
||||
console.log('⤴️ Update type', powerbi.VisualUpdateType[options.type])
|
||||
try {
|
||||
this.throttleUpdate(
|
||||
processMatrixView(
|
||||
validationResult.view,
|
||||
this.host,
|
||||
validationResult.hasColorFilter,
|
||||
(obj, id) => this.selectionHandler.set(obj, id)
|
||||
)
|
||||
this.tooltipHandler.setup(input.objectTooltipData)
|
||||
this.throttleUpdate(input)
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Data update error', error ?? 'Unknown')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDataUpdate(input: SpeckleDataInput, signal: AbortSignal) {
|
||||
console.log('DATA UPDATE', input)
|
||||
await this.viewerHandler.selectObjects(null)
|
||||
await this.viewerHandler.loadObjectsWithAutoUnload(
|
||||
input.objectsToLoad,
|
||||
this.onLoad,
|
||||
this.onError,
|
||||
signal
|
||||
)
|
||||
if (signal.aborted) {
|
||||
console.warn('Aborted')
|
||||
return
|
||||
}
|
||||
|
||||
await this.viewerHandler.colorObjectsByGroup(input.colorByIds)
|
||||
await this.viewerHandler.unIsolateObjects()
|
||||
if (input.selectedIds.length == 0)
|
||||
await this.viewerHandler.isolateObjects(input.objectIds, true)
|
||||
else await this.viewerHandler.isolateObjects(input.selectedIds, true)
|
||||
}
|
||||
|
||||
public getFormattingModel(): powerbi.visuals.FormattingModel {
|
||||
@@ -159,82 +112,12 @@ export class Visual implements IVisual {
|
||||
}
|
||||
|
||||
private throttleUpdate = _.throttle((input: SpeckleDataInput) => {
|
||||
this.viewerHandler.init().then(async () => {
|
||||
if (this.updateTask) {
|
||||
this.ac.abort()
|
||||
console.log('Cancelling previous load job')
|
||||
await this.updateTask
|
||||
this.ac = new AbortController()
|
||||
}
|
||||
// Handle the update in data passed to this visual
|
||||
this.updateTask = this.handleDataUpdate(input, this.ac.signal).then(() => {
|
||||
this.ac = new AbortController()
|
||||
this.updateTask = undefined
|
||||
})
|
||||
})
|
||||
this.tooltipHandler.setup(input.objectTooltipData)
|
||||
store.commit('setInput', input)
|
||||
store.commit('setStatus', 'valid')
|
||||
}, 500)
|
||||
|
||||
private onLoad(url: string, index: number) {
|
||||
console.log(`Loaded object ${url} with index ${index}`)
|
||||
}
|
||||
|
||||
private onError(url: string, error: Error) {
|
||||
console.warn(`Error loading object ${url} with error`, error)
|
||||
this.host.displayWarningIcon(
|
||||
'Load error',
|
||||
`One or more objects could not be loaded
|
||||
Please ensure that the stream you're trying to access is PUBLIC
|
||||
The Speckle PowerBI Viewer cannot handle private streams yet.
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
private onPointerMove = (_) => {
|
||||
this.moved = true
|
||||
}
|
||||
private onPointerDown = (_) => {
|
||||
this.moved = false
|
||||
this.target.addEventListener('pointermove', this.onPointerMove)
|
||||
}
|
||||
private onPointerUp = (_) => {
|
||||
this.target.removeEventListener('pointermove', this.onPointerMove)
|
||||
}
|
||||
|
||||
private onClick = async (ev) => {
|
||||
if (this.moved) return
|
||||
const intersectResult = await this.viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
|
||||
const multi = isMultiSelect(ev)
|
||||
const hit = intersectResult?.hit
|
||||
if (hit) {
|
||||
const id = hit.object.id as string
|
||||
|
||||
if (multi || !this.selectionHandler.isSelected(id))
|
||||
await this.selectionHandler.select(id, multi)
|
||||
|
||||
this.tooltipHandler.show(hit, { x: ev.clientX, y: ev.clientY })
|
||||
const selection = this.selectionHandler.getCurrentSelection()
|
||||
const ids = selection.map((s) => s.id)
|
||||
await this.viewerHandler.selectObjects(ids)
|
||||
} else {
|
||||
this.tooltipHandler.hide()
|
||||
if (!multi) {
|
||||
this.selectionHandler.clear()
|
||||
await this.viewerHandler.selectObjects(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
private onAuxClick = async (ev) => {
|
||||
if (ev.button != 2 || this.moved) return
|
||||
const intersectResult = await this.viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
|
||||
await this.selectionHandler.showContextMenu(ev, intersectResult?.hit)
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
await this.clear()
|
||||
this.viewerHandler.dispose()
|
||||
this.target.removeEventListener('pointerup', this.onPointerUp)
|
||||
this.target.removeEventListener('pointerdown', this.onPointerDown)
|
||||
this.target.removeEventListener('click', this.onClick)
|
||||
this.target.removeEventListener('auxclick', this.onAuxClick)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap");
|
||||
@import '@speckle/ui-components/style.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
p {
|
||||
font-size: 15px;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
#speckle-app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.speckle-landing {
|
||||
@@ -0,0 +1,11 @@
|
||||
const speckleTheme = require("@speckle/tailwind-theme");
|
||||
const themeConfig = require("@speckle/tailwind-theme/tailwind-configure");
|
||||
const uiConfig = require("@speckle/ui-components/tailwind-configure");
|
||||
const formsPlugin = require("@tailwindcss/forms");
|
||||
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
module.exports = {
|
||||
darkMode: "class",
|
||||
content: ["./src/**/*.{js,ts,vue}", themeConfig.tailwindContentEntry(require), uiConfig.tailwindContentEntry(require)],
|
||||
plugins: [speckleTheme.default, formsPlugin]
|
||||
};
|
||||
+20
-2
@@ -11,8 +11,26 @@
|
||||
"lib": ["es2020", "dom"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"types": []
|
||||
"types": [],
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"src/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"assets/*": [
|
||||
"./assets/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
},
|
||||
"files": ["./src/visual.ts"],
|
||||
"include": ["./src/**/*.ts"]
|
||||
"include": ["./src/**/*.ts", "./src/**/*.vue"],
|
||||
"exclude": [
|
||||
"webpack.config.dev.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import path from 'path'
|
||||
|
||||
// api configuration
|
||||
import powerbi from 'powerbi-visuals-api'
|
||||
import ExtraWatchWebpackPlugin from 'extra-watch-webpack-plugin'
|
||||
import { BundleAnalyzerPlugin as Visualizer } from 'webpack-bundle-analyzer'
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
|
||||
import PowerBICustomVisualsWebpackPlugin from 'powerbi-visuals-webpack-plugin'
|
||||
import webpack from 'webpack'
|
||||
import fs from 'fs'
|
||||
import { WebpackConfiguration } from 'webpack-cli'
|
||||
import { VueLoaderPlugin } from 'vue-loader'
|
||||
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const powerbiApi: any = powerbi // Types for PowerBI seem to be off, so I'm instead forcing it to `any`
|
||||
|
||||
// visual configuration json path
|
||||
const pbivizPath = './pbiviz.json'
|
||||
const pbivizFile = require(path.join(__dirname, pbivizPath))
|
||||
|
||||
// the visual capabilities content
|
||||
const capabilitiesPath = './capabilities.json'
|
||||
const capabilitiesFile = require(path.join(__dirname, capabilitiesPath))
|
||||
|
||||
const pluginLocation = './.tmp/precompile/visualPlugin.ts' // path to visual plugin file, the file generates by the plugin
|
||||
|
||||
// string resources
|
||||
const resourcesFolder = path.join('.', 'stringResources')
|
||||
const localizationFolders = fs.existsSync(resourcesFolder) && fs.readdirSync(resourcesFolder)
|
||||
const statsLocation = '../../webpack.statistics.html'
|
||||
|
||||
// babel options to support IE11
|
||||
const babelOptions = {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
useBuiltIns: 'entry',
|
||||
corejs: 3,
|
||||
modules: false
|
||||
}
|
||||
]
|
||||
],
|
||||
plugins: [],
|
||||
sourceType: 'unambiguous', // tell to babel that the project can contain different module types, not only es2015 modules
|
||||
cacheDirectory: path.join('.tmp', 'babelCache') // path for cache files
|
||||
}
|
||||
|
||||
const config: WebpackConfiguration = {
|
||||
entry: {
|
||||
visual: pluginLocation
|
||||
},
|
||||
optimization: {
|
||||
concatenateModules: false,
|
||||
minimize: false // enable minimization for create *.pbiviz file less than 2 Mb, can be disabled for dev mode
|
||||
},
|
||||
devtool: 'source-map',
|
||||
mode: 'development',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'vue-loader'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
parser: {
|
||||
amd: false
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /(\.ts)x|\.ts$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: [
|
||||
// '@babel/react',
|
||||
'@babel/env'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: false,
|
||||
experimentalWatchApi: false,
|
||||
appendTsSuffixTo: [/\.vue$/]
|
||||
}
|
||||
}
|
||||
],
|
||||
exclude: [/node_modules/],
|
||||
include: /.tmp|powerbi-visuals-|src|precompile\\visualPlugin.ts/
|
||||
},
|
||||
{
|
||||
test: /(\.js)x|\.js$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: babelOptions
|
||||
}
|
||||
],
|
||||
exclude: [/node_modules/]
|
||||
},
|
||||
{
|
||||
test: /\.json$/,
|
||||
loader: 'json-loader',
|
||||
type: 'javascript/auto'
|
||||
},
|
||||
{
|
||||
test: /\.(css|scss)?$/,
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
|
||||
},
|
||||
{
|
||||
test: /\.(woff|ttf|ico|woff2|jpg|jpeg|png|webp|svg)$/i,
|
||||
use: ['base64-inline-loader']
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.css'],
|
||||
alias: {
|
||||
src: path.resolve(__dirname, 'src/'),
|
||||
assets: path.resolve(__dirname, 'assets/')
|
||||
}
|
||||
},
|
||||
output: {
|
||||
publicPath: '/assets',
|
||||
path: path.join(__dirname, '/.tmp', 'drop'),
|
||||
library: +powerbiApi.version.replace(/\./g, '') >= 320 ? pbivizFile.visual.guid : undefined,
|
||||
libraryTarget: +powerbiApi.version.replace(/\./g, '') >= 320 ? 'var' : undefined
|
||||
},
|
||||
devServer: {
|
||||
static: {
|
||||
directory: path.join(__dirname, '.tmp', 'drop'), // path with assets for dev server, they are generated by webpack plugin
|
||||
publicPath: '/assets'
|
||||
},
|
||||
compress: true,
|
||||
port: 8080, // dev server port
|
||||
hot: false,
|
||||
https: {},
|
||||
liveReload: false,
|
||||
webSocketServer: false,
|
||||
headers: {
|
||||
'access-control-allow-origin': '*',
|
||||
'cache-control': 'public, max-age=0'
|
||||
}
|
||||
},
|
||||
externals:
|
||||
powerbiApi.version.replace(/\./g, '') >= 320
|
||||
? {
|
||||
'powerbi-visuals-api': 'null',
|
||||
fakeDefine: 'false'
|
||||
}
|
||||
: {
|
||||
'powerbi-visuals-api': 'null',
|
||||
fakeDefine: 'false',
|
||||
corePowerbiObject: "Function('return this.powerbi')()",
|
||||
realWindow: "Function('return this')()"
|
||||
},
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
new TsconfigPathsPlugin(),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'visual.css',
|
||||
chunkFilename: '[id].css'
|
||||
}),
|
||||
new Visualizer({
|
||||
reportFilename: statsLocation,
|
||||
openAnalyzer: false,
|
||||
analyzerMode: `static`
|
||||
}),
|
||||
// visual plugin regenerates with the visual source, but it does not require relaunching dev server
|
||||
new webpack.WatchIgnorePlugin({
|
||||
paths: [path.join(__dirname, pluginLocation), './.tmp/**/*.*']
|
||||
}),
|
||||
// custom visuals plugin instance with options
|
||||
new PowerBICustomVisualsWebpackPlugin({
|
||||
...pbivizFile,
|
||||
compression: 0,
|
||||
capabilities: capabilitiesFile,
|
||||
stringResources:
|
||||
localizationFolders &&
|
||||
localizationFolders.map((localization) =>
|
||||
path.join(resourcesFolder, localization, 'resources.resjson')
|
||||
),
|
||||
apiVersion: powerbiApi.version,
|
||||
capabilitiesSchema: powerbiApi.schemas.capabilities,
|
||||
pbivizSchema: powerbiApi.schemas.pbiviz,
|
||||
stringResourcesSchema: powerbiApi.schemas.stringResources,
|
||||
dependenciesSchema: powerbiApi.schemas.dependencies,
|
||||
devMode: false,
|
||||
generatePbiviz: false,
|
||||
generateResources: true,
|
||||
minifyJS: false,
|
||||
minify: false,
|
||||
modules: true,
|
||||
visualSourceLocation: '../../src/visual',
|
||||
pluginLocation: pluginLocation,
|
||||
packageOutPath: path.join(__dirname, 'dist')
|
||||
}),
|
||||
new ExtraWatchWebpackPlugin({
|
||||
files: [pbivizPath, capabilitiesPath]
|
||||
}),
|
||||
powerbiApi.version.replace(/\./g, '') >= 320
|
||||
? new webpack.ProvidePlugin({
|
||||
define: 'fakeDefine'
|
||||
})
|
||||
: new webpack.ProvidePlugin({
|
||||
window: 'realWindow',
|
||||
define: 'fakeDefine',
|
||||
powerbi: 'corePowerbiObject'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,228 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import path from 'path'
|
||||
|
||||
// api configuration
|
||||
import powerbi from 'powerbi-visuals-api'
|
||||
import ExtraWatchWebpackPlugin from 'extra-watch-webpack-plugin'
|
||||
import { BundleAnalyzerPlugin as Visualizer } from 'webpack-bundle-analyzer'
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
|
||||
import PowerBICustomVisualsWebpackPlugin from 'powerbi-visuals-webpack-plugin'
|
||||
import webpack from 'webpack'
|
||||
import fs from 'fs'
|
||||
import { WebpackConfiguration } from 'webpack-cli'
|
||||
import { VueLoaderPlugin } from 'vue-loader'
|
||||
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const powerbiApi: any = powerbi // Types for PowerBI seem to be off, so I'm instead forcing it to `any`
|
||||
|
||||
// visual configuration json path
|
||||
const pbivizPath = './pbiviz.json'
|
||||
const pbivizFile = require(path.join(__dirname, pbivizPath))
|
||||
|
||||
// the visual capabilities content
|
||||
const capabilitiesPath = './capabilities.json'
|
||||
const capabilitiesFile = require(path.join(__dirname, capabilitiesPath))
|
||||
|
||||
const pluginLocation = './.tmp/precompile/visualPlugin.ts' // path to visual plugin file, the file generates by the plugin
|
||||
|
||||
// string resources
|
||||
const resourcesFolder = path.join('.', 'stringResources')
|
||||
const localizationFolders = fs.existsSync(resourcesFolder) && fs.readdirSync(resourcesFolder)
|
||||
const statsLocation = '../../webpack.statistics.html'
|
||||
|
||||
// babel options to support IE11
|
||||
const babelOptions = {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
targets: {
|
||||
ie: '11'
|
||||
},
|
||||
useBuiltIns: 'entry',
|
||||
corejs: 3,
|
||||
modules: false
|
||||
}
|
||||
]
|
||||
],
|
||||
plugins: [],
|
||||
sourceType: 'unambiguous', // tell to babel that the project can contain different module types, not only es2015 modules
|
||||
cacheDirectory: path.join('.tmp', 'babelCache') // path for cache files
|
||||
}
|
||||
|
||||
const config: WebpackConfiguration = {
|
||||
entry: {
|
||||
visual: pluginLocation
|
||||
},
|
||||
optimization: {
|
||||
concatenateModules: false,
|
||||
minimize: true // enable minimization for create *.pbiviz file less than 2 Mb, can be disabled for dev mode
|
||||
},
|
||||
devtool: 'source-map',
|
||||
mode: 'development',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'vue-loader'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
parser: {
|
||||
amd: false
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /(\.ts)x|\.ts$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: [
|
||||
// '@babel/react',
|
||||
'@babel/env'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: false,
|
||||
experimentalWatchApi: false,
|
||||
appendTsSuffixTo: [/\.vue$/]
|
||||
}
|
||||
}
|
||||
],
|
||||
exclude: [/node_modules/],
|
||||
include: /.tmp|powerbi-visuals-|src|precompile\\visualPlugin.ts/
|
||||
},
|
||||
{
|
||||
test: /(\.js)x|\.js$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: babelOptions
|
||||
}
|
||||
],
|
||||
exclude: [/node_modules/]
|
||||
},
|
||||
{
|
||||
test: /\.json$/,
|
||||
loader: 'json-loader',
|
||||
type: 'javascript/auto'
|
||||
},
|
||||
{
|
||||
test: /\.(css|scss)?$/,
|
||||
use: [MiniCssExtractPlugin.loader, 'style-loader', 'css-loader', 'postcss-loader']
|
||||
},
|
||||
{
|
||||
test: /\.(woff|ttf|ico|woff2|jpg|jpeg|png|webp|svg)$/i,
|
||||
use: [
|
||||
{
|
||||
loader: 'base64-inline-loader'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.css'],
|
||||
alias: {}
|
||||
},
|
||||
output: {
|
||||
publicPath: '/assets',
|
||||
path: path.join(__dirname, '/.tmp', 'drop'),
|
||||
filename: '[name]',
|
||||
sourceMapFilename: '[name].js.map',
|
||||
// if API version of the visual is higher/equal than 3.2.0 add library and libraryTarget options into config
|
||||
// API version less than 3.2.0 doesn't require it
|
||||
library: +powerbiApi.version.replace(/\./g, '') >= 320 ? pbivizFile.visual.guid : undefined,
|
||||
libraryTarget: +powerbiApi.version.replace(/\./g, '') >= 320 ? 'var' : undefined
|
||||
},
|
||||
devServer: {
|
||||
allowedHosts: 'all',
|
||||
static: {
|
||||
directory: path.join(__dirname, '.tmp', 'drop'), // path with assets for dev server, they are generated by webpack plugin
|
||||
publicPath: '/assets'
|
||||
},
|
||||
compress: true,
|
||||
port: 8080, // dev server port
|
||||
hot: false,
|
||||
liveReload: false,
|
||||
headers: {
|
||||
'access-control-allow-origin': '*',
|
||||
'cache-control': 'public, max-age=0'
|
||||
}
|
||||
},
|
||||
externals:
|
||||
powerbiApi.version.replace(/\./g, '') >= 320
|
||||
? {
|
||||
'powerbi-visuals-api': 'null',
|
||||
fakeDefine: 'false'
|
||||
}
|
||||
: {
|
||||
'powerbi-visuals-api': 'null',
|
||||
fakeDefine: 'false',
|
||||
corePowerbiObject: "Function('return this.powerbi')()",
|
||||
realWindow: "Function('return this')()"
|
||||
},
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
new TsconfigPathsPlugin(),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'visual.css',
|
||||
chunkFilename: '[id].css'
|
||||
}),
|
||||
new Visualizer({
|
||||
reportFilename: statsLocation,
|
||||
openAnalyzer: false,
|
||||
analyzerMode: `static`
|
||||
}),
|
||||
// visual plugin regenerates with the visual source, but it does not require relaunching dev server
|
||||
new webpack.WatchIgnorePlugin({
|
||||
paths: [path.join(__dirname, pluginLocation), './.tmp/**/*.*']
|
||||
}),
|
||||
// custom visuals plugin instance with options
|
||||
new PowerBICustomVisualsWebpackPlugin({
|
||||
...pbivizFile,
|
||||
compression: 9,
|
||||
capabilities: capabilitiesFile,
|
||||
stringResources:
|
||||
localizationFolders &&
|
||||
localizationFolders.map((localization) =>
|
||||
path.join(resourcesFolder, localization, 'resources.resjson')
|
||||
),
|
||||
apiVersion: powerbiApi.version,
|
||||
capabilitiesSchema: powerbiApi.schemas.capabilities,
|
||||
pbivizSchema: powerbiApi.schemas.pbiviz,
|
||||
stringResourcesSchema: powerbiApi.schemas.stringResources,
|
||||
dependenciesSchema: powerbiApi.schemas.dependencies,
|
||||
devMode: false,
|
||||
generatePbiviz: true,
|
||||
generateResources: true,
|
||||
modules: true,
|
||||
visualSourceLocation: '../../src/visual',
|
||||
pluginLocation: pluginLocation,
|
||||
packageOutPath: path.join(__dirname, 'dist')
|
||||
}),
|
||||
new ExtraWatchWebpackPlugin({
|
||||
files: [pbivizPath, capabilitiesPath]
|
||||
}),
|
||||
powerbiApi.version.replace(/\./g, '') >= 320
|
||||
? new webpack.ProvidePlugin({
|
||||
define: 'fakeDefine'
|
||||
})
|
||||
: new webpack.ProvidePlugin({
|
||||
window: 'realWindow',
|
||||
define: 'fakeDefine',
|
||||
powerbi: 'corePowerbiObject'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols Disabled to prevent warning, Webpack uses this inherently.
|
||||
export default config
|
||||
Reference in New Issue
Block a user