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:
Alan Rynne
2023-05-23 16:25:44 +02:00
committed by GitHub
parent 67cae270b6
commit c7066c2242
31 changed files with 7283 additions and 873 deletions
+3 -1
View File
@@ -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'
+1
View File
@@ -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
View File
@@ -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"
}
},
{
+6189 -562
View File
File diff suppressed because it is too large Load Diff
+46 -15
View File
@@ -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
View File
@@ -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": []
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
'postcss-nesting': {}
}
}
+19
View File
@@ -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>
+85
View File
@@ -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>
+156
View File
@@ -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>
+8
View File
@@ -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>
+29
View File
@@ -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>
+51
View File
@@ -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 }
}
-68
View File
@@ -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
}
-2
View File
@@ -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()
+1 -4
View File
@@ -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)
}
+38 -21
View File
@@ -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()
}
}
+10
View File
@@ -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()
+6
View File
@@ -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
}
+35
View File
@@ -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
View File
@@ -8,7 +8,7 @@ export interface IViewerTooltip {
data: IViewerTooltipData[]
}
export type SpeckleDataInput = {
export interface SpeckleDataInput {
objectsToLoad: string[]
objectIds: string[]
selectedIds: string[]
+6 -4
View File
@@ -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)
+22
View File
@@ -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>
+9
View File
@@ -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
View File
@@ -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)
}
}
+8 -4
View File
@@ -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 {
+11
View File
@@ -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
View File
@@ -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"
]
}
+222
View File
@@ -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
+228
View File
@@ -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