13 Commits

Author SHA1 Message Date
Alan Rynne 67cae270b6 chore: Viewer not verbose and hidden stats 2023-05-17 10:27:21 +02:00
Alan Rynne 1e417a6720 Merge branch 'dev' into main 2023-05-17 10:23:37 +02:00
Alan Rynne 51f17d476a bump version 2023-05-17 10:23:24 +02:00
Alan Rynne 124e1f186c fix: Apply data reduction algorithm to matrix view as expected 2023-05-17 10:23:18 +02:00
Alan Rynne de5154b41d fix: Initialize with DefaultParams so default environment will be preserved 2023-05-16 10:07:38 +02:00
Alan Rynne 895b9bf688 bump version and removed leftover line 2023-05-12 10:23:46 +02:00
Alan Rynne 956a7f94ee hack: Remember initial colors to keep them consistent after slicing (#26)
This should be properly fixed when we implement the new FormattingModel class in our visual
2023-05-12 10:05:52 +02:00
Alan Rynne 569baecc3e Major visual refactor (#25)
* feat: Moved viewer logic into `ViewerHandler` class

* feat: Moved some logic around to simplify visual structure

* fix: Minor tweaks and fixes

* fix: More tweaks and fixes based on refactor

* feat: Major upgrade to loading commits instead of objects

* feat: Continued major refactor for "commit loading"

Selection both ways, tooltips, right-click...

* feat: file reordering into subfolders

* fix: Minor selection improvements

* fix: More selection and loading improvements

* fix: UnIsolateObjects now uses state

mouse events in main viewer now deal with dragging to prevent false click possitives.

* fix: Loading with and without color filters

* fix: Handle unload of no longer used objects
2023-05-11 22:54:38 +02:00
Alan Rynne c491f298c5 Dependency upgrade (#24)
* chore: Updated all packages to latest

* chore: Removed unused github actions
2023-04-26 14:03:25 +02:00
Alan Rynne ec9f9c7cd8 feat: Adopted eslint instead of tslint (#23) 2023-04-26 13:59:50 +02:00
Alan Rynne 7e35700cfa feat: Custom tooltips and independent color filters (#20)
* Added new inputs to capabilities.json

* Added initial tooltipHandler class

* working custom tooltips

* fix: Last minute null ref bug on tooltip data
2023-02-07 14:37:53 +01:00
Alan Rynne 39cfa33baf chore: TSConfig changes 2023-02-05 13:27:25 +01:00
Alan Rynne a1fbba71e4 refactor: Moved landing page logic to it's own file 2023-02-05 13:27:13 +01:00
28 changed files with 5520 additions and 3329 deletions
+35
View File
@@ -0,0 +1,35 @@
/** @type {import("eslint").Linter.Config} */
const config = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
requireConfigFile: false,
ecmaVersion: 2020,
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier'
],
env: {
node: true,
commonjs: true
},
ignorePatterns: [
'node_modules',
'dist',
'public',
'events.json',
'.*.{ts,js,vue,tsx,jsx}',
'generated/**/*'
],
rules: {
'no-var': 'off',
'@typescript-eslint/ban-ts-comment': 'warn'
}
}
module.exports = config
-12
View File
@@ -1,12 +0,0 @@
name: Update issue Status
on:
issues:
types: [closed]
jobs:
update_issue:
uses: specklesystems/github-actions/.github/workflows/project-update-issue-status.yml@main
secrets: inherit
with:
issue-id: ${{ github.event.issue.node_id }}
-12
View File
@@ -1,12 +0,0 @@
name: Move new issues into Project
on:
issues:
types: [opened]
jobs:
track_issue:
uses: specklesystems/github-actions/.github/workflows/project-add-issue.yml@main
secrets: inherit
with:
issue-id: ${{ github.event.issue.node_id }}
+2 -1
View File
@@ -3,4 +3,5 @@ dist/
webpack.statistics.dev.html
.tmp/
webpack.statistics.prod.html
.DS_Store
.DS_Store
.idea/
+11
View File
@@ -0,0 +1,11 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": false,
"endOfLine": "auto",
"bracketSpacing": true,
"vueIndentScriptAndStyle": false,
"htmlWhitespaceSensitivity": "ignore",
"printWidth": 100,
"singleQuote": true
}
+79 -51
View File
@@ -2,69 +2,78 @@
"dataRoles": [
{
"displayName": "Stream URL",
"name": "stream",
"kind": "Grouping"
"kind": "Grouping",
"name": "stream"
},
{
"displayName": "Commit Object ID",
"kind": "Grouping",
"name": "parentObject"
},
{
"displayName": "Object ID",
"name": "object",
"kind": "GroupingOrMeasure"
"kind": "Grouping",
"name": "object"
},
{
"displayName": "Object Data",
"name": "objectData",
"kind": "Measure"
"displayName": "Color By",
"kind": "GroupingOrMeasure",
"name": "objectColorBy"
},
{
"displayName": "Tooltip Data",
"kind": "Measure",
"name": "objectData"
}
],
"dataViewMappings": [
{
"categorical": {
"categories": {
"matrix": {
"rows": {
"dataReductionAlgorithm": {
"top": {
"count": 30000
}
},
"select": [
{
"for": {
"in": "stream"
}
},
{
"for": {
"in": "parentObject"
}
},
{
"for": {
"in": "objectColorBy"
}
},
{
"for": {
"in": "object"
}
}
],
"dataReductionAlgorithm": {
"top": {
"count": 30000
}
}
]
},
"values": {
"for": { "in": "objectData" }
"select": [
{
"bind": {
"to": "objectData"
}
}
]
}
}
}
],
"supportsHighlight": true,
"supportsMultiVisualSelection": true,
"suppressDefaultTitle": true,
"supportsSynchronizingFilterState": true,
"supportsLandingPage": true,
"supportsEmptyDataView": true,
"supportsKeyboardFocus": true,
"tooltips": {
"supportEnhancedTooltips": true
},
"drilldown": {
"roles": ["stream", "object"]
},
"objects": {
"camera": {
"displayName": "Camera",
"properties": {
"orthoMode": {
"displayName": "Ortho mode",
"type": { "bool": true }
},
"defaultView": {
"displayName": "Default view",
"type": {
@@ -101,24 +110,20 @@
}
]
}
},
"orthoMode": {
"displayName": "Ortho mode",
"type": {
"bool": true
}
}
}
},
"color": {
"displayName": "Color",
"properties": {
"startColor": {
"displayName": "Start Color",
"type": {
"fill": {
"solid": {
"color": true
}
}
}
},
"midColor": {
"displayName": "Middle Color",
"background": {
"displayName": "Background Color",
"type": {
"fill": {
"solid": {
@@ -137,8 +142,18 @@
}
}
},
"background": {
"displayName": "Background Color",
"midColor": {
"displayName": "Middle Color",
"type": {
"fill": {
"solid": {
"color": true
}
}
}
},
"startColor": {
"displayName": "Start Color",
"type": {
"fill": {
"solid": {
@@ -152,8 +167,8 @@
},
"privileges": [
{
"name": "WebAccess",
"essential": true,
"name": "WebAccess",
"parameters": [
"https://speckle.xyz",
"https://*.speckle.xyz",
@@ -164,8 +179,21 @@
]
},
{
"name": "ExportContent",
"essential": false
"essential": false,
"name": "ExportContent"
}
]
],
"sorting": {
"default": {}
},
"supportsEmptyDataView": true,
"supportsHighlight": true,
"supportsKeyboardFocus": true,
"supportsLandingPage": true,
"supportsMultiVisualSelection": true,
"supportsSynchronizingFilterState": true,
"suppressDefaultTitle": true,
"tooltips": {
"supportEnhancedTooltips": true
}
}
+4354 -2708
View File
File diff suppressed because it is too large Load Diff
+29 -19
View File
@@ -1,37 +1,47 @@
{
"name": "visual",
"description": "default_template_value",
"name": "@specklesystems/powerbi-visual",
"description": "A 3D viewer for Speckle Object in PowerBI",
"repository": {
"type": "default_template_value",
"url": "default_template_value"
"type": "github",
"url": "https://github.com/specklesystems/speckle-powerbi-visuals"
},
"license": "MIT",
"scripts": {
"pbiviz": "pbiviz",
"start": "pbiviz start",
"package": "pbiviz package",
"lint": "tslint -c tslint.json -p tsconfig.json"
"lint": "eslint -c .eslintrc.js --ext .ts src/"
},
"dependencies": {
"@babel/runtime": "7.6.0",
"@babel/runtime-corejs2": "7.6.0",
"@speckle/viewer": "^2.11.4",
"@babel/runtime": "7.21.5",
"@babel/runtime-corejs2": "7.21.5",
"@speckle/viewer": "^2.13.3",
"color-interpolate": "^1.0.5",
"core-js": "3.2.1",
"core-js": "3.30.2",
"lodash": "^4.17.21",
"powerbi-visuals-api": "~5.1.0",
"powerbi-visuals-utils-dataviewutils": "2.4.1",
"powerbi-visuals-utils-interactivityutils": "^5.7.1",
"powerbi-visuals-utils-tooltiputils": "^2.5.2",
"regenerator-runtime": "^0.13.9"
"powerbi-visuals-api": "~5.4.0",
"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"
},
"devDependencies": {
"@babel/core": "^7.21.8",
"@babel/eslint-parser": "^7.21.8",
"@babel/preset-env": "^7.21.5",
"@types/core-js": "^2.5.5",
"@types/lodash": "^4.14.188",
"@types/lodash": "^4.14.194",
"@types/regenerator-runtime": "^0.13.1",
"ts-loader": "6.1.0",
"tslint": "^5.18.0",
"tslint-microsoft-contrib": "^6.2.0",
"typescript": "3.6.3"
"@types/three": "^0.150.1",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.40.0",
"eslint-config-prettier": "^8.8.0",
"prettier": "^2.8.8",
"ts-loader": "^9.4.2",
"typescript": "^5.0.4",
"user-agent-data-types": "^0.3.1"
}
}
+3 -3
View File
@@ -4,15 +4,15 @@
"displayName": "Speckle PowerBI Viewer",
"guid": "powerbiSpeckleVisualAA98F06515D847E8ACB33BAB487244E0",
"visualClassName": "Visual",
"version": "2.0.0-alpha5",
"version": "2.0.0-alpha8",
"description": "An interactive 3D viewer for Speckle Data",
"supportUrl": "https://speckle.community",
"gitHubUrl": "https://github.com/specklesystems/speckle-powerbi-visuals"
},
"apiVersion": "5.1.0",
"apiVersion": "5.4.0",
"author": { "name": "Speckle Systems", "email": "info@speckle.systems" },
"assets": { "icon": "assets/logo.png" },
"externalJS": null,
"externalJS": [],
"style": "style/visual.less",
"capabilities": "capabilities.json",
"dependencies": null,
+68
View File
@@ -0,0 +1,68 @@
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
}
+63
View File
@@ -0,0 +1,63 @@
export default class SelectionHandler {
private selectionIdMap: Map<string, powerbi.extensibility.ISelectionId>
private currentSelection: Set<string>
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()
this.selectionIdMap = new Map<string, powerbi.extensibility.ISelectionId>()
this.currentSelection = new Set<string>()
}
public async showContextMenu(ev: MouseEvent, hit?) {
const selectionId = !hit ? null : this.selectionIdMap.get(hit?.object?.id)
return this.selectionManager.showContextMenu(selectionId, {
x: ev.clientX,
y: ev.clientY
})
}
public set(url: string, data: powerbi.extensibility.ISelectionId) {
this.selectionIdMap.set(url, data)
}
public async select(url: string, multi = false) {
if (multi) {
await this.selectionManager.select(this.selectionIdMap.get(url), true)
if (this.currentSelection.has(url)) this.currentSelection.delete(url)
else this.currentSelection.add(url)
} else {
await this.selectionManager.select(this.selectionIdMap.get(url), false)
this.currentSelection.clear()
this.currentSelection.add(url)
}
}
public getCurrentSelection(): { id: string; selectionId: powerbi.extensibility.ISelectionId }[] {
return [...this.currentSelection].map((entry) => ({
id: entry,
selectionId: this.selectionIdMap.get(entry)
}))
}
public isSelected(id: string) {
return this.currentSelection.has(id)
}
public clear() {
this.selectionManager.clear()
this.currentSelection.clear()
}
public reset() {
this.clear()
this.selectionIdMap.clear()
}
public has(url) {
return this.selectionIdMap.has(url)
}
}
+55
View File
@@ -0,0 +1,55 @@
import powerbi from 'powerbi-visuals-api'
import ITooltipService = powerbi.extensibility.ITooltipService
import { IViewerTooltip } from '../types'
import { SpeckleTooltip } from '../interfaces'
export default class TooltipHandler {
private data: Map<string, IViewerTooltip>
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>()
}
public setup(data: Map<string, IViewerTooltip>) {
this.data = data
}
public show(hit: { guid: string; object?; point }, screenLoc) {
const id = hit.object.id as string
const objTooltipData: IViewerTooltip = this.data.get(id)
if (!objTooltipData) return
const tooltipData = {
coordinates: [screenLoc.x, screenLoc.y],
dataItems: objTooltipData.data,
identities: [objTooltipData.selectionId],
isTouchEvent: false
}
this.currentTooltip = {
id: hit.object.id,
worldPos: hit.point,
screenPos: screenLoc,
tooltip: tooltipData
}
this.tooltipService.show(tooltipData)
}
public hide() {
this.tooltipService.hide({ immediately: true, isTouchEvent: false })
this.currentTooltip = null
}
public move() {
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)
}
}
+192
View File
@@ -0,0 +1,192 @@
import {
CanonicalView,
FilteringState,
Viewer,
IntersectionQuery,
DefaultViewerParams
} from '@speckle/viewer'
import { createViewerContainerDiv, pickViewableHit, projectToScreen } from '../utils/viewerUtils'
import { SpeckleVisualSettings } from '../settings'
import { SettingsChangedType, Tracker } from '../utils/mixpanel'
import _ from 'lodash'
export default class ViewerHandler {
private viewer: Viewer
private readonly parent: HTMLElement
private state: FilteringState
private loadedObjectsCache: Set<string> = new Set<string>()
private settings = {
authToken: null,
batchSize: 25
}
public OnCameraUpdate: () => void
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()
else this.viewer.cameraHandler?.setPerspectiveCameraOn()
}
if (oldSettings.camera.defaultView != newSettings.camera.defaultView) {
Tracker.settingsChanged(SettingsChangedType.DefaultCamera)
this.viewer.setView(newSettings.camera.defaultView as CanonicalView)
}
}
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)
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(
objects: string[],
signal?: AbortSignal,
onObjectUnloaded?: (url: string) => void
) {
console.log('Unloading objects')
for (const url of objects) {
if (signal?.aborted) return
await this.viewer
.cancelLoad(url, true)
.catch((e) => console.warn('Viewer Unload error', url, e))
.finally(() => {
if (this.loadedObjectsCache.has(url)) this.loadedObjectsCache.delete(url)
if (onObjectUnloaded) onObjectUnloaded(url)
})
}
}
public async loadObjectsWithAutoUnload(
objectUrls: string[],
onLoad: (url: string, index: number) => void,
onError: (url: string, error: Error) => void,
signal: AbortSignal
) {
var objectsToUnload = _.difference([...this.loadedObjectsCache], objectUrls)
await this.unloadObjects(objectsToUnload, signal)
await this.loadObjects(objectUrls, onLoad, onError, signal)
}
public async loadObjects(
objectUrls: string[],
onLoad: (url: string, index: number) => void,
onError: (url: string, error: Error) => void,
signal: AbortSignal
) {
console.groupCollapsed('Loading objects')
try {
let index = 0
let promises = []
for (const url of objectUrls) {
if (signal?.aborted) return
console.log('Attempting to load', url)
if (!this.loadedObjectsCache.has(url)) {
console.log('Object is not in cache')
const promise = this.viewer
.loadObjectAsync(url, this.settings.authToken, false)
.then(() => onLoad(url, index++))
.catch((e: Error) => onError(url, e))
.finally(() => {
if (!this.loadedObjectsCache.has(url)) this.loadedObjectsCache.add(url)
})
promises.push(promise)
if (promises.length == this.settings.batchSize) {
//this.promises.push(Promise.resolve(this.later(1000)))
await Promise.all(promises)
promises = []
}
} else {
console.log('Object was already in cache')
}
}
await Promise.all(promises)
} catch (error) {
throw new Error(`Load objects failed: ${error}`)
} finally {
console.groupEnd()
}
}
public async intersect(coords: { x: number; y: number }) {
const point = this.viewer.Utils.screenToNDC(
coords.x,
coords.y,
this.parent.clientWidth,
this.parent.clientHeight
)
const intQuery: IntersectionQuery = {
operation: 'Pick',
point
}
const res = this.viewer.query(intQuery)
if (!res) return null
return {
hit: pickViewableHit(res.objects, this.state),
objects: res.objects
}
}
public async unIsolateObjects() {
if (this.state.isolatedObjects)
this.state = await this.viewer.unIsolateObjects(this.state.isolatedObjects, 'powerbi', true)
}
public async isolateObjects(objectIds, ghost = false) {
this.state = await this.viewer.isolateObjects(objectIds, 'powerbi', true, ghost)
}
public async colorObjectsByGroup(
groups?: {
objectIds: string[]
color: string
}[]
) {
//@ts-ignore
this.state = await this.viewer.setUserObjectColors(groups ?? [])
}
public async clear() {
if (this.viewer) await this.viewer.unloadAll()
this.loadedObjectsCache.clear()
}
public async selectObjects(objectIds: string[] = null) {
await this.viewer.resetHighlight()
const objIds = objectIds ?? []
this.state = await this.viewer.selectObjects(objIds)
}
public getScreenPosition(worldPosition): { x: number; y: number } {
return projectToScreen(this.viewer.cameraHandler.camera, worldPosition)
}
public dispose() {
this.viewer.cameraHandler.controls.removeAllEventListeners()
this.viewer.dispose()
this.viewer = null
}
}
+20
View File
@@ -0,0 +1,20 @@
import { IViewerTooltipData } from './types'
export interface SpeckleSelectionData {
id: powerbi.extensibility.ISelectionId
data: IViewerTooltipData[]
}
export interface SpeckleTooltip {
worldPos: {
x: number
y: number
z: number
}
screenPos: {
x: number
y: number
}
tooltip
id: string
}
-26
View File
@@ -1,26 +0,0 @@
"use strict"
import { dataViewObjectsParser } from "powerbi-visuals-utils-dataviewutils"
import DataViewObjectsParser = dataViewObjectsParser.DataViewObjectsParser
export class SpeckleVisualSettings extends DataViewObjectsParser {
public camera: CameraSettings = new CameraSettings()
public color: ColorSettings = new ColorSettings()
}
export class CameraSettings {
// Default color
public orthoMode: boolean = false
public defaultView: string = "default"
}
export class ColorSettings {
public startColor: string = "#31c116"
public midColor: string = "#fc8032"
public endColor: string = "#e70000"
public background: string = "#ffffff"
public getColorList() {
return [this.startColor, this.midColor, this.endColor]
}
}
+45
View File
@@ -0,0 +1,45 @@
'use strict'
import { dataViewObjectsParser } from 'powerbi-visuals-utils-dataviewutils'
import DataViewObjectsParser = dataViewObjectsParser.DataViewObjectsParser
import _ from 'lodash'
import { SpeckleVisualSettingsModel } from './visualSettingsModel'
import { CanonicalView } from '@speckle/viewer/dist/IViewer'
export class CameraSettings {
// Default color
public orthoMode = false
public defaultView: CanonicalView = '3D'
}
export class ColorSettings {
public startColor = '#31c116'
public midColor = '#fc8032'
public endColor = '#e70000'
public background = '#ffffff'
}
export class SpeckleVisualSettings extends DataViewObjectsParser {
public camera: CameraSettings
public color: ColorSettings
public static OnSettingsChanged: (oldSettings, newSettings) => void
public constructor() {
super()
this.camera = new CameraSettings()
this.color = new ColorSettings()
}
public static current: SpeckleVisualSettings = new SpeckleVisualSettings()
public static async handleSettingsUpdate(newSettings: SpeckleVisualSettings) {
const same = _.isEqual(this.current, newSettings)
if (same) return
this.OnSettingsChanged(this.current, newSettings)
this.current = newSettings
}
public static async handleSettingsModelUpdate(newSettings: SpeckleVisualSettingsModel) {
this.current.color.background = newSettings.colorsCard.backgroundColorSlice.value.value
}
}
+51
View File
@@ -0,0 +1,51 @@
import { formattingSettings } from 'powerbi-visuals-utils-formattingmodel'
import { SpeckleVisualSettings } from './'
import FormattingSettingsCard = formattingSettings.Card
import FormattingSettingsModel = formattingSettings.Model
import FormattingSettingsSlice = formattingSettings.Slice
export class SpeckleVisualSettingsModel extends FormattingSettingsModel {
// Building my visual formatting settings card
colorsCard: SpeckleVisualColorSettingsCard = new SpeckleVisualColorSettingsCard()
// Add formatting settings card to cards list in model
cards = [this.colorsCard]
}
class SpeckleVisualColorSettingsCard extends FormattingSettingsCard {
public startColorSlice = new formattingSettings.ColorPicker({
name: 'startColor',
displayName: 'Start Color',
value: { value: '#ffffff' },
defaultColor: { value: '#ffffff' }
})
public midColorSlice = new formattingSettings.ColorPicker({
name: 'midColor',
displayName: 'Mid Color',
value: { value: SpeckleVisualSettings.current.color.midColor }
})
public endColorSlice = new formattingSettings.ColorPicker({
name: 'endColor',
displayName: 'End Color',
value: { value: SpeckleVisualSettings.current.color.endColor }
})
public backgroundColorSlice = new formattingSettings.ColorPicker({
name: 'backgroundColor',
displayName: 'Background Color',
value: { value: SpeckleVisualSettings.current.color.background }
})
name = 'speckleVisual_colors'
displayName = 'Colors'
analyticsPane = false
slices: Array<FormattingSettingsSlice> = [
this.startColorSlice,
this.midColorSlice,
this.endColorSlice,
this.backgroundColorSlice
]
}
+18
View File
@@ -0,0 +1,18 @@
export interface IViewerTooltipData {
displayName: string
value: string
}
export interface IViewerTooltip {
selectionId: powerbi.extensibility.ISelectionId
data: IViewerTooltipData[]
}
export type SpeckleDataInput = {
objectsToLoad: string[]
objectIds: string[]
selectedIds: string[]
colorByIds: { objectIds: string[]; color: string }[]
objectTooltipData: Map<string, IViewerTooltip>
view: powerbi.DataViewMatrix
}
-61
View File
@@ -1,61 +0,0 @@
import powerbi from "powerbi-visuals-api"
export function VisualUpdateTypeToString(type: powerbi.VisualUpdateType) {
switch (type) {
case powerbi.VisualUpdateType.Resize:
return "Resize"
case powerbi.VisualUpdateType.ResizeEnd:
return "ResizeEnd"
case powerbi.VisualUpdateType.Style:
return "Style"
case powerbi.VisualUpdateType.ViewMode:
return "ViewMode"
case powerbi.VisualUpdateType.Resize + powerbi.VisualUpdateType.ResizeEnd:
return "Resize+ResizeEnd"
case powerbi.VisualUpdateType.Data:
return "Data"
case powerbi.VisualUpdateType.All:
return "All"
}
}
export function cleanupDataColumnName(name: string) {
var cleanName = name
var simplePrefixes = ["First", "Last"]
var compoundPrefixes = [
"Count",
"Sum",
"Average",
"Minimum",
"Maximum",
"Count",
"Standard deviation",
"Variance",
"Median"
].map(prefix => prefix + " of")
var prefixes = [...simplePrefixes, ...compoundPrefixes].map(
prefix => prefix + " "
)
for (let i = 0; i < prefixes.length; i++) {
const prefix = prefixes[i]
if (name.startsWith(prefix)) {
cleanName = name.slice(prefix.length)
break
}
}
if (cleanName.startsWith("data.")) cleanName = cleanName.split("data.")[0]
return cleanName
}
export function projectToScreen(cam: any, loc: any) {
cam.updateProjectionMatrix()
var copy = loc.clone()
copy.project(cam)
return {
x: (copy.x * 0.5 + 0.5) * window.innerWidth - 10,
y: (copy.y * -0.5 + 0.5) * window.innerHeight
}
}
+24
View File
@@ -0,0 +1,24 @@
// Add data types to window.navigator for use in this file. See https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html#-reference-types- for more info.
/// <reference types="user-agent-data-types" />
export function getOS(): OS {
const platform = window.navigator?.userAgentData?.platform || window.navigator.platform,
macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K', 'macOS'],
windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']
let os = null
if (macosPlatforms.indexOf(platform) !== -1) {
os = 'MacOS'
} else if (windowsPlatforms.indexOf(platform) !== -1) {
os = 'Windows'
} else if (/Linux/.test(platform)) {
os = 'Linux'
}
return os
}
export enum OS {
Windows,
MacOS,
Linux
}
export const currentOS = getOS()
+7
View File
@@ -0,0 +1,7 @@
import { currentOS, OS } from './detectOS'
export function isMultiSelect(e: MouseEvent) {
if (!e) return false
if (currentOS === OS.MacOS) return e.metaKey || e.shiftKey
else return e.ctrlKey || e.shiftKey
}
+163
View File
@@ -0,0 +1,163 @@
import powerbi from 'powerbi-visuals-api'
import { IViewerTooltip, IViewerTooltipData, SpeckleDataInput } from '../types'
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions
import { hasRole } from 'powerbi-visuals-utils-dataviewutils/lib/dataRoleHelper'
import { LOD } from 'three'
export function validateMatrixView(options: VisualUpdateOptions): {
hasColorFilter: boolean
view: powerbi.DataViewMatrix
} {
const matrixVew = options.dataViews[0].matrix
if (!matrixVew) throw new Error('Data does not contain a matrix data view')
let hasStream = false,
hasParentObject = false,
hasObject = false,
hasColorFilter = false
matrixVew.rows.levels.forEach((level) => {
level.sources.forEach((source) => {
if (!hasStream) hasStream = source.roles['stream'] != undefined
if (!hasParentObject) hasParentObject = source.roles['parentObject'] != undefined
if (!hasObject) hasObject = source.roles['object'] != undefined
if (!hasColorFilter) hasColorFilter = source.roles['objectColorBy'] != undefined
})
})
if (!hasStream) throw new Error('Missing Stream ID input')
if (!hasParentObject) throw new Error('Missing Commit Object ID input')
if (!hasObject) throw new Error('Missing Object Id input')
return {
hasColorFilter,
view: matrixVew
}
}
function processObjectValues(
objectIdChild: powerbi.DataViewMatrixNode,
matrixView: powerbi.DataViewMatrix
) {
const objectData: IViewerTooltipData[] = []
let shouldColor = true,
shouldSelect = false
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
const isHighlighted = value.highlight !== null
if (highLightActive && isHighlighted) {
shouldSelect = true
shouldColor = true
}
const propData: IViewerTooltipData = {
displayName: colInfo.displayName,
value: value.value.toString()
}
objectData.push(propData)
})
return { data: objectData, shouldColor, shouldSelect }
}
function processObjectNode(
objectIdChild: powerbi.DataViewMatrixNode,
host: powerbi.extensibility.visual.IVisualHost,
matrixView: powerbi.DataViewMatrix
) {
const objId = objectIdChild.value as string
// Create selection IDs for each object
const nodeSelection = host
.createSelectionIdBuilder()
.withMatrixNode(objectIdChild, matrixView.rows.levels)
.createSelectionId()
// Create value records for the tooltips
if (objectIdChild.values) {
var objectValues = processObjectValues(objectIdChild, matrixView)
}
return { id: objId, selectionId: nodeSelection, ...objectValues }
}
function processObjectIdLevel(
parentObjectIdChild: powerbi.DataViewMatrixNode,
host: powerbi.extensibility.visual.IVisualHost,
matrixView: powerbi.DataViewMatrix
) {
return parentObjectIdChild.children?.map((objectIdChild) =>
processObjectNode(objectIdChild, host, matrixView)
)
}
var previousPalette = null
var previousPaletteKey = null
export function processMatrixView(
matrixView: powerbi.DataViewMatrix,
host: powerbi.extensibility.visual.IVisualHost,
hasColorFilter: boolean,
onSelectionPair: (objId: string, selectionId: powerbi.extensibility.ISelectionId) => void
): SpeckleDataInput {
const objectUrlsToLoad = [],
objectIds = [],
selectedIds = [],
colorByIds = [],
objectTooltipData = new Map<string, IViewerTooltip>()
matrixView.rows.root.children.forEach((streamUrlChild) => {
const url = streamUrlChild.value
streamUrlChild.children?.forEach((parentObjectIdChild) => {
const parentId = parentObjectIdChild.value
objectUrlsToLoad.push(`${url}/objects/${parentId}`)
if (!hasColorFilter) {
console.log('🖌️❌ NO COLOR FILTER')
processObjectIdLevel(parentObjectIdChild, host, matrixView).forEach((objRes) => {
objectIds.push(objRes.id)
onSelectionPair(objRes.id, objRes.selectionId)
if (objRes.shouldSelect) selectedIds.push(objRes.id)
objectTooltipData.set(objRes.id, {
selectionId: objRes.selectionId,
data: objRes.data
})
})
} else {
if (previousPalette) host.colorPalette['colorPalette'] = previousPalette
parentObjectIdChild.children?.forEach((colorByChild) => {
const color = host.colorPalette.getColor(colorByChild.value as string)
const colorGroup = {
color: color.value,
objectIds: []
}
processObjectIdLevel(colorByChild, host, matrixView).forEach((objRes) => {
objectIds.push(objRes.id)
onSelectionPair(objRes.id, objRes.selectionId)
if (objRes.shouldSelect) selectedIds.push(objRes.id)
if (objRes.shouldColor) {
colorGroup.objectIds.push(objRes.id)
}
objectTooltipData.set(objRes.id, {
selectionId: objRes.selectionId,
data: objRes.data
})
})
if (colorGroup.objectIds.length > 0) colorByIds.push(colorGroup)
})
}
})
})
previousPalette = host.colorPalette['colorPalette']
return {
objectsToLoad: objectUrlsToLoad,
objectIds,
selectedIds,
colorByIds: colorByIds.length > 0 ? colorByIds : null,
objectTooltipData,
view: matrixView
}
}
+15 -18
View File
@@ -1,17 +1,17 @@
const TRACK_URL = "https://analytics.speckle.systems/track?ip=1"
const MIXPANEL_TOKEN = "acd87c5a50b56df91a795e999812a3a4"
const HOST_APP_NAME = "powerbi-visual"
const TRACK_URL = 'https://analytics.speckle.systems/track?ip=1'
const MIXPANEL_TOKEN = 'acd87c5a50b56df91a795e999812a3a4'
const HOST_APP_NAME = 'powerbi-visual'
export enum Event {
Create = "Create",
Reload = "Reload",
Settings = "Settings"
Create = 'Create',
Reload = 'Reload',
Settings = 'Settings'
}
export enum SettingsChangedType {
Gradient = "Gradient",
DefaultCamera = "DefaultCamera",
OrthoMode = "OrthoMode"
Gradient = 'Gradient',
DefaultCamera = 'DefaultCamera',
OrthoMode = 'OrthoMode'
}
export class Tracker {
@@ -24,16 +24,14 @@ export class Tracker {
])
}
private static async trackEvents(
events: Array<{ event: Event; properties: any }>
) {
private static async trackEvents(events: Array<{ event: Event; properties: any }>) {
try {
var res = await fetch(TRACK_URL, {
method: "POST",
await fetch(TRACK_URL, {
method: 'POST',
body:
"data=" +
'data=' +
JSON.stringify(
events.map(e => {
events.map((e) => {
Object.assign(e.properties, {
token: MIXPANEL_TOKEN,
hostApp: HOST_APP_NAME
@@ -42,9 +40,8 @@ export class Tracker {
})
)
})
//console.log("Create track", res, await res.json())
} catch (e) {
console.error("Create track failed", e)
console.error('Create track failed', e)
}
}
+65
View File
@@ -0,0 +1,65 @@
// MIT License
// Copyright (c) 2022 Davide Aversa
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import * as _ from 'lodash'
export interface SignalBindingAsync<S, T> {
listener?: string
handler: (source: S, data: T) => Promise<void>
}
export interface IAsyncSignal<S, T> {
bind(listener: string, handler: (source: S, data: T) => Promise<void>): void
unbind(listener: string): void
}
export class AsyncSignal<S, T> implements IAsyncSignal<S, T> {
private handlers: Array<SignalBindingAsync<S, T>> = []
public bind(listener: string, handler: (source: S, data: T) => Promise<void>): void {
if (this.contains(listener)) {
this.unbind(listener)
}
this.handlers.push({ listener, handler })
}
public unbind(listener: string): void {
this.handlers = this.handlers.filter((h) => h.listener !== listener)
}
public async trigger(source: S, data: T): Promise<void> {
// Duplicate the array to avoid side effects during iteration.
this.handlers.slice(0).map((h) => h.handler(source, data))
}
public async triggerAwait(source: S, data: T): Promise<void> {
// Duplicate the array to avoid side effects during iteration.
const promises = this.handlers.slice(0).map((h) => h.handler(source, data))
await Promise.all(promises)
}
public contains(listener: string): boolean {
return _.some(this.handlers, (h) => h.listener === listener)
}
public expose(): IAsyncSignal<S, T> {
return this
}
}
+37
View File
@@ -0,0 +1,37 @@
import { FilteringState } from '@speckle/viewer'
export function projectToScreen(cam, loc) {
cam.updateProjectionMatrix()
const copy = loc.clone()
copy.project(cam)
return {
x: (copy.x * 0.5 + 0.5) * window.innerWidth - 10,
y: (copy.y * -0.5 + 0.5) * window.innerHeight
}
}
export interface Hit {
guid: string
object?: Record<string, unknown>
point: { x: number; y: number; z: number }
}
export function pickViewableHit(hits: Hit[], state: FilteringState): Hit | null {
let hit = null
if (state.isolatedObjects) {
// Find the first hit contained in the isolated objects
hit = hits.find((hit) => {
const hitId = hit.object.id as string
return state.isolatedObjects.includes(hitId)
})
}
return hit
}
export const createViewerContainerDiv = (parent: HTMLElement) => {
const container = parent.appendChild(document.createElement('div'))
container.style.backgroundColor = 'transparent'
container.style.height = '100%'
container.style.width = '100%'
container.style.position = 'fixed'
return container
}
+177 -405
View File
@@ -1,468 +1,240 @@
"use strict"
import 'core-js/stable'
import 'regenerator-runtime/runtime'
import './../style/visual.less'
import "core-js/stable"
import "regenerator-runtime/runtime" /* <---- add this line */
import "./../style/visual.less"
import * as _ from 'lodash'
import { FormattingSettingsService } from 'powerbi-visuals-utils-formattingmodel'
import powerbi from "powerbi-visuals-api"
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'
import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructorOptions
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions
import ITooltipService = powerbi.extensibility.ITooltipService
import IVisual = powerbi.extensibility.visual.IVisual
import EnumerateVisualObjectInstancesOptions = powerbi.EnumerateVisualObjectInstancesOptions
import VisualObjectInstance = powerbi.VisualObjectInstance
import DataView = powerbi.DataView
import VisualObjectInstanceEnumerationObject = powerbi.VisualObjectInstanceEnumerationObject
import interpolate from "color-interpolate"
import { SpeckleVisualSettings } from "./settings"
import {
Viewer,
CanonicalView,
ViewerEvent,
PropertyInfo
} from "@speckle/viewer"
import _ from "lodash"
import {
VisualUpdateTypeToString,
cleanupDataColumnName,
projectToScreen
} from "./utils"
import { SettingsChangedType, Tracker } from "./mixpanel"
interface SpeckleTooltip {
worldPos: {
x: number
y: number
z: number
}
screenPos: {
x: number
y: number
}
tooltip: any
id: string
}
import ITooltipService = powerbi.extensibility.ITooltipService
import { isMultiSelect } from './utils/isMultiSelect'
// noinspection JSUnusedGlobalSymbols
export class Visual implements IVisual {
private target: HTMLElement
private settings: SpeckleVisualSettings
private host: powerbi.extensibility.IVisualHost
private selectionManager: powerbi.extensibility.ISelectionManager
private tooltipService: ITooltipService
private selectionIdMap: Map<string, powerbi.extensibility.ISelectionId>
private viewer: Viewer
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 currentOrthoMode: boolean = false
private currentDefaultView: string = "default"
private currentTooltip: SpeckleTooltip = null
private moved = false
constructor(options: VisualConstructorOptions) {
// noinspection JSUnusedGlobalSymbols
public constructor(options: VisualConstructorOptions) {
Tracker.loaded()
this.host = options.host
this.selectionIdMap = new Map<string, powerbi.extensibility.ISelectionId>()
//@ts-ignore
this.selectionManager = this.host.createSelectionManager()
//@ts-ignore
this.tooltipService = this.host.tooltipService as ITooltipService
this.target = options.element
}
this.formattingSettingsService = new FormattingSettingsService()
public async initViewer() {
if (this.viewer) return
console.log('🚀 Init handlers')
var container = this.createContainerDiv()
const viewer = new Viewer(container)
await viewer.init()
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)
// Setup any events here (progress, load-complete...)
viewer.on(ViewerEvent.ObjectClicked, this.onObjectClicked)
viewer.on(ViewerEvent.ObjectDoubleClicked, this.onObjectDoubleClicked)
viewer.cameraHandler.controls.addEventListener(
"update",
this.throttleCameraUpdate
console.log('🚀 Setup handler events')
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
)
this.viewer = viewer
SpeckleVisualSettings.OnSettingsChanged = (oldSettings, newSettings) => {
this.viewerHandler.changeSettings(oldSettings, newSettings)
}
//Show landing Page by default
this.landingPageHandler.show()
}
private async clear() {
this.ac.abort()
await this.updateTask
await this.viewerHandler.clear()
this.selectionHandler.clear()
this.ac = new AbortController()
}
public update(options: VisualUpdateOptions) {
this.settings = Visual.parseSettings(
options && options.dataViews && options.dataViews[0]
this.formattingSettings = this.formattingSettingsService.populateFormattingSettingsModel(
SpeckleVisualSettingsModel,
options.dataViews
)
SpeckleVisualSettings.handleSettingsModelUpdate(this.formattingSettings)
this.HandleLandingPage(options)
if (this.isLandingPageOn) return
console.log(
`Update was called with update type ${VisualUpdateTypeToString(
options.type
)}`,
options,
this.settings
)
// TODO: These cases are not being handled right now, we will skip the update logic.
// Some are already handled by our viewer, such as resize, but others may require custom implementations in the future.
switch (options.type) {
case powerbi.VisualUpdateType.Resize:
case powerbi.VisualUpdateType.ResizeEnd:
case powerbi.VisualUpdateType.Style:
case powerbi.VisualUpdateType.ViewMode:
case powerbi.VisualUpdateType.Resize + powerbi.VisualUpdateType.ResizeEnd:
// Ignore case, nothing will happen
return
}
console.log("Data was updated, updating viewer...")
this.debounceUpdate(options)
}
private async handleSettingsUpdate(options: VisualUpdateOptions) {
// Handle change in ortho mode
if (this.currentOrthoMode != this.settings.camera.orthoMode) {
if (this.settings.camera.orthoMode)
this.viewer?.cameraHandler?.setOrthoCameraOn()
else this.viewer?.cameraHandler?.setPerspectiveCameraOn()
this.currentOrthoMode = this.settings.camera.orthoMode
Tracker.settingsChanged(SettingsChangedType.OrthoMode)
}
// Handle change in default view
if (this.currentDefaultView != this.settings.camera.defaultView) {
this.viewer.setView(this.settings.camera.defaultView as CanonicalView)
this.currentDefaultView = this.settings.camera.defaultView
Tracker.settingsChanged(SettingsChangedType.DefaultCamera)
}
// Update bg of viewer
this.target.style.backgroundColor = this.settings.color.background
}
private async handleDataUpdate(
options: VisualUpdateOptions,
signal: AbortSignal
) {
var categoricalView = options.dataViews[0].categorical
var streamCategory = categoricalView?.categories[0]?.values
var objectIdCategory = categoricalView?.categories[1]?.values
var highlightedValues = categoricalView?.values
? categoricalView?.values[0].highlights
: null
if (!streamCategory || !objectIdCategory) {
// If some of the fields are not filled in, unload everything
//@ts-ignore
try {
console.log('🔍 Validating input...', options)
var validationResult = validateMatrixView(options)
console.log('✅Input valid', validationResult)
this.landingPageHandler.hide()
} catch (e) {
console.log('❌Input not valid:', (e as Error).message)
this.host.displayWarningIcon(
`Incomplete data input.`,
`"Stream URL" and "Object ID" data inputs are mandatory`
)
console.warn(
`Incomplete data input. "Stream URL" and "Object ID" data inputs are mandatory`
)
await this.viewer.unloadAll()
this.selectionIdMap = new Map<string, any>()
console.warn(`Incomplete data input. "Stream URL" and "Object ID" data inputs are mandatory`)
this.clear()
this.landingPageHandler.show()
return
}
var objectUrls = streamCategory.map(
(stream, index) => `${stream}/objects/${objectIdCategory[index]}`
)
var objectsToUnload = []
for (const key of this.selectionIdMap.keys()) {
const found = objectUrls.find(url => url == key)
if (!found) {
objectsToUnload.push(key)
try {
switch (options.type) {
case powerbi.VisualUpdateType.Resize:
case powerbi.VisualUpdateType.ResizeEnd:
case powerbi.VisualUpdateType.Style:
case powerbi.VisualUpdateType.ViewMode:
case powerbi.VisualUpdateType.Resize + powerbi.VisualUpdateType.ResizeEnd:
return
default:
var input = processMatrixView(
validationResult.view,
this.host,
validationResult.hasColorFilter,
(obj, id) => this.selectionHandler.set(obj, id)
)
this.tooltipHandler.setup(input.objectTooltipData)
this.throttleUpdate(input)
}
}
console.log(
`Viewer loading ${objectUrls.length} and unloading ${objectsToUnload.length}`
)
for (const url of objectsToUnload) {
if (signal?.aborted) return
await this.viewer
.cancelLoad(url, true)
.then(_ => {
this.selectionIdMap.delete(url)
})
.catch(e => console.warn("Viewer Unload error", url, e))
}
var index = 0
var promises = []
const batchSize = 25
for (const url of objectUrls) {
if (signal?.aborted) return
if (!this.selectionIdMap.has(url)) {
var promise = this.viewer
.loadObject(url, null, false)
.catch((e: Error) => {
//@ts-ignore
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.`
)
console.warn("Viewer Load error:", url, e.name)
})
promises.push(promise)
if (promises.length == batchSize) {
await Promise.all(promises)
promises = []
}
}
// We create selection Ids for all objects, regardless if they're there already.
//@ts-ignore
var selectionBuilder = this.host.createSelectionIdBuilder()
var selectionId = selectionBuilder
.withCategory(categoricalView?.categories[1], index)
.createSelectionId()
this.selectionIdMap.set(url, selectionId)
index++
}
await Promise.all(promises)
if (signal?.aborted) return
Tracker.dataReload()
var colorList = this.settings.color.getColorList()
if (categoricalView?.values) {
var name = categoricalView?.values[0].source.displayName
var objectIds = highlightedValues
? highlightedValues
.map((value, index) =>
value ? objectIdCategory[index].toString() : null
)
.filter(e => e != null)
: null
if (objectIds) {
await this.viewer.resetFilters()
await this.viewer.isolateObjects(objectIds, null, true, true)
} else {
await this.viewer.resetFilters()
}
var prop = this.viewer
.getObjectProperties(null, true)
.find(item => item.key == cleanupDataColumnName(name))
if (prop.type == "number") {
var groups = this.getCustomColorGroups(prop, colorList)
//@ts-ignore
await this.viewer.setUserObjectColors(groups)
} else {
await this.viewer.setColorFilter(prop).catch(async e => {
console.warn("Filter failed to be applied. Filter will be reset", e)
return await this.viewer.removeColorFilter()
})
}
} else {
await this.viewer.resetFilters()
this.viewer.zoom()
} catch (error) {
console.error('Data update error', error ?? 'Unknown')
}
}
private static parseSettings(dataView: DataView): SpeckleVisualSettings {
return <SpeckleVisualSettings>SpeckleVisualSettings.parse(dataView)
}
/**
* This function gets called for each of the objects defined in the capabilities files and allows you to select which of the
* objects and properties you want to expose to the users in the property pane.
*
*/
public enumerateObjectInstances(
options: EnumerateVisualObjectInstancesOptions
): VisualObjectInstance[] | VisualObjectInstanceEnumerationObject {
return SpeckleVisualSettings.enumerateObjectInstances(
this.settings || SpeckleVisualSettings.getDefault(),
options
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)
}
private debounceUpdate = _.debounce(options => {
this.initViewer().then(async _ => {
public getFormattingModel(): powerbi.visuals.FormattingModel {
return this.formattingSettingsService.buildFormattingModel(this.formattingSettings)
}
private throttleUpdate = _.throttle((input: SpeckleDataInput) => {
this.viewerHandler.init().then(async () => {
if (this.updateTask) {
this.ac.abort()
console.log("Cancelling previous load job")
console.log('Cancelling previous load job')
await this.updateTask
this.ac = new AbortController()
}
// Handle changes in the visual objects
this.handleSettingsUpdate(options)
console.log("Updating viewer with new data")
// Handle the update in data passed to this visual
this.updateTask = this.handleDataUpdate(options, this.ac.signal).then(
() => (this.updateTask = undefined)
)
this.updateTask = this.handleDataUpdate(input, this.ac.signal).then(() => {
this.ac = new AbortController()
this.updateTask = undefined
})
})
}, 500)
private throttleCameraUpdate = _.throttle(options => {
if (!this.currentTooltip) return
var newScreenLoc = projectToScreen(
this.viewer.cameraHandler.camera,
this.currentTooltip.worldPos
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.
`
)
this.currentTooltip.tooltip.coordinates = [newScreenLoc.x, newScreenLoc.y]
this.tooltipService.move(this.currentTooltip.tooltip)
}, 1000.0 / 60.0)
private onObjectClicked = arg => {
if (!arg) {
this.tooltipService.hide({ immediately: true, isTouchEvent: false })
this.currentTooltip = null
this.viewer.resetSelection()
this.selectionManager.clear()
return
}
var hit = arg.hits[0]
this.viewer.selectObjects([hit.object.id])
this.showTooltip(hit)
this.selectionManager.select(this.selectionIdMap.get(hit.guid), false)
}
private onObjectDoubleClicked = arg => {
if (!arg) return
var hit = arg.hits[0]
var selectionId = this.selectionIdMap.get(hit.guid)
const screenLoc = projectToScreen(
this.viewer.cameraHandler.camera,
hit.point
)
this.selectionManager.showContextMenu(selectionId, screenLoc)
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 createContainerDiv() {
var container = this.target.appendChild(document.createElement("div"))
container.style.backgroundColor = "transparent"
container.style.height = "100%"
container.style.width = "100%"
container.style.position = "fixed"
return container
}
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
private showTooltip(hit: any) {
var selectionId = this.selectionIdMap.get(hit.guid)
const screenLoc = projectToScreen(
this.viewer.cameraHandler.camera,
hit.point
)
var dataItems = Object.keys(hit.object)
.filter(key => !key.startsWith("__"))
.map(key => {
return {
displayName: key,
value: hit.object[key]
}
})
if (multi || !this.selectionHandler.isSelected(id))
await this.selectionHandler.select(id, multi)
const tooltipData = {
coordinates: [screenLoc.x, screenLoc.y],
dataItems: dataItems,
identities: [selectionId],
isTouchEvent: false
}
this.currentTooltip = {
id: hit.object.id,
worldPos: hit.point,
screenPos: screenLoc,
tooltip: tooltipData
}
this.tooltipService.show(tooltipData)
}
private isLandingPageOn = false
private LandingPageRemoved = false
private LandingPage: Element = null
private HandleLandingPage(options: VisualUpdateOptions) {
console.log("handle landing page")
if (
!options.dataViews ||
!options.dataViews[0]?.metadata?.columns?.length
) {
if (!this.isLandingPageOn) {
this.isLandingPageOn = true
const SampleLandingPage: Element = this.createSampleLandingPage() //create a landing page
this.LandingPage = SampleLandingPage
}
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 {
if (this.isLandingPageOn && !this.LandingPageRemoved) {
this.LandingPageRemoved = true
this.target.removeChild(this.LandingPage)
this.isLandingPageOn = false
this.tooltipHandler.hide()
if (!multi) {
this.selectionHandler.clear()
await this.viewerHandler.selectObjects(null)
}
}
}
createSampleLandingPage(): Element {
var container = this.target.appendChild(document.createElement("div"))
container.classList.add("speckle-landing")
var img = document.createElement("div")
img.classList.add("speckle-logo")
container.appendChild(img)
var subtext = document.createElement("p")
subtext.classList.add("heading")
subtext.textContent = "PowerBI 3D Viewer"
container.appendChild(subtext)
var tipContainer = document.createElement("div")
tipContainer.classList.add("tip-container")
var tip = document.createElement("p")
tip.textContent = "Getting started 💡"
tip.classList.add("tip")
tipContainer.appendChild(tip)
var instructions = document.createElement("p")
instructions.classList.add("instructions")
instructions.textContent =
"Please connect the Stream ID and Object ID fields."
tipContainer.appendChild(instructions)
var 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)
var instructions2 = document.createElement("p")
instructions2.classList.add("instructions")
instructions2.classList.add("docs")
instructions2.innerHTML =
"For more info, check our docs page <b>https://speckle.guide</b>"
tipContainer.appendChild(instructions2)
container.appendChild(tipContainer)
return container
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)
}
private getCustomColorGroups(prop: PropertyInfo, customColors: string[]) {
var groups: [{ value: number; id?: string; ids?: string[] }] =
//@ts-ignore
prop.valueGroups
if (!groups) return null
var colorGrad = interpolate(customColors)
return groups.map(group => {
//@ts-ignore
var color = colorGrad((group.value - prop.min) / (prop.max - prop.min))
var objectIds = group.ids ?? [group.id]
return {
objectIds,
color
}
})
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)
}
}
+7 -4
View File
@@ -3,13 +3,16 @@
"allowJs": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es6",
"target": "es2020",
"sourceMap": true,
"outDir": "./.tmp/build/",
"moduleResolution": "node",
"declaration": true,
"lib": ["es2015", "dom"],
"allowSyntheticDefaultImports": true
"lib": ["es2020", "dom"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"types": []
},
"files": ["./src/visual.ts"]
"files": ["./src/visual.ts"],
"include": ["./src/**/*.ts"]
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "tslint-microsoft-contrib/recommended",
"rulesDirectory": [
"node_modules/tslint-microsoft-contrib"
],
"rules": {
"no-relative-imports": false
}
}