16 Commits

Author SHA1 Message Date
Alan Rynne 10b8a68e32 ci: Use correct action 2023-01-26 23:53:22 +01:00
Alan Rynne 9e00cc85a8 bump: Alpha5 2023-01-26 16:32:27 +01:00
Alan Rynne 047f763465 feat: Added batch loading to viewer (#15) 2023-01-26 16:28:21 +01:00
Alan Rynne fa33719902 feat: Upgraded viewer to latest version (#12)
* feat: Upgraded viewer to latest version

* bump viewer 2.10.2

* bump powerbi version

* feat: Adds user-facing error for when data is incomplete or loading fails

* feat: Tooltips and selection

* refactor: Moved projectToScreen to utils

* refactor: Moved events into specific functions

* fix: Fixed numerical filter coloring

* feat: Added landing page to visual

* fix: Filters not being cleared

* chore: Remove leftover console.logs
2023-01-23 09:24:14 +01:00
Alan Rynne d60f0ba2b6 ci: Updated github actions to use new actions repo 2023-01-09 20:45:48 +01:00
Alan Rynne aac875664d fixes #14: Updates github actions with same code as speckle-sharp 2022-12-06 12:09:16 +01:00
Alan Rynne ae6231f5f1 fix: Moved data reduction setting to main categorical input 2022-11-17 12:54:19 +01:00
Alan Rynne d839573d96 bump: Version 2.0.0-alpha3 2022-11-09 22:09:22 +01:00
Alan Rynne 50c7118bff fix: Properly handle missing fields after loading for the first time 2022-11-09 22:02:38 +01:00
Alan Rynne d21033cd85 fix: Load up to 30k items (the maximum allowed)
Use at your own peril
2022-11-08 22:49:40 +01:00
Alan Rynne 6240ec724f Merge pull request #9 from specklesystems/tracking-events
Fixes #5: Add tracking to powerbi visual
2022-11-08 22:42:26 +01:00
Alan Rynne 3248c5ad14 fix: Adds tracking to powerbi visual 2022-11-08 22:41:28 +01:00
Alan Rynne 917c7ee8a6 feat: Added initial tracking on creation 2022-11-08 18:14:53 +01:00
Alan Rynne 17c1f1c3f2 fix: Minor visual.ts cleanup 2022-11-08 17:34:21 +01:00
Alan Rynne 3bcc8c34d6 fix: Use AbortController instead of custom cancel flag 2022-11-08 16:28:00 +01:00
Alan Rynne e9834636e6 fix: Prevents over-calling of loadObject, which can overwhelm the server 2022-11-08 15:44:44 +01:00
12 changed files with 2680 additions and 604 deletions
+4 -70
View File
@@ -6,73 +6,7 @@ on:
jobs:
update_issue:
runs-on: ubuntu-latest
steps:
- name: Get project data
env:
GITHUB_TOKEN: ${{secrets.GHPROJECT_TOKEN}}
ORGANIZATION: specklesystems
PROJECT_NUMBER: 9
run: |
gh api graphql --header 'GraphQL-Features: projects_next_graphql' -f query='
query($org: String!, $number: Int!) {
organization(login: $org){
projectNext(number: $number) {
id
fields(first:20) {
nodes {
id
name
settings
}
}
}
}
}' -f org=$ORGANIZATION -F number=$PROJECT_NUMBER > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo "$PROJECT_ID"
echo "$STATUS_FIELD_ID"
echo 'DONE_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") | .settings | fromjson | .options[] | select(.name== "Done") | .id' project_data.json) >> $GITHUB_ENV
echo "$DONE_ID"
- name: Add Issue to project #it's already in the project, but we do this to get its node id!
env:
GITHUB_TOKEN: ${{secrets.GHPROJECT_TOKEN}}
ISSUE_ID: ${{ github.event.issue.node_id }}
run: |
item_id="$( gh api graphql --header 'GraphQL-Features: projects_next_graphql' -f query='
mutation($project:ID!, $id:ID!) {
addProjectNextItem(input: {projectId: $project, contentId: $id}) {
projectNextItem {
id
}
}
}' -f project=$PROJECT_ID -f id=$ISSUE_ID --jq '.data.addProjectNextItem.projectNextItem.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Update Status
env:
GITHUB_TOKEN: ${{secrets.GHPROJECT_TOKEN}}
ISSUE_ID: ${{ github.event.issue.node_id }}
run: |
gh api graphql --header 'GraphQL-Features: projects_next_graphql' -f query='
mutation($project:ID!, $status:ID!, $id:ID!, $value:String!) {
set_status: updateProjectNextItemField(
input: {
projectId: $project
itemId: $id
fieldId: $status
value: $value
}
) {
projectNextItem {
id
}
}
}' -f project=$PROJECT_ID -f status=$STATUS_FIELD_ID -f id=$ITEM_ID -f value=${{ env.DONE_ID }}
uses: specklesystems/github-actions/.github/workflows/project-update-issue-status.yml@main
secrets: inherit
with:
issue-id: ${{ github.event.issue.node_id }}
+4 -42
View File
@@ -6,45 +6,7 @@ on:
jobs:
track_issue:
runs-on: ubuntu-latest
steps:
- name: Get project data
env:
GITHUB_TOKEN: ${{secrets.GHPROJECT_TOKEN}}
ORGANIZATION: specklesystems
PROJECT_NUMBER: 9
run: |
gh api graphql --header 'GraphQL-Features: projects_next_graphql' -f query='
query($org: String!, $number: Int!) {
organization(login: $org){
projectNext(number: $number) {
id
fields(first:20) {
nodes {
id
name
settings
}
}
}
}
}' -f org=$ORGANIZATION -F number=$PROJECT_NUMBER > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
- name: Add Issue to project
env:
GITHUB_TOKEN: ${{secrets.GHPROJECT_TOKEN}}
ISSUE_ID: ${{ github.event.issue.node_id }}
run: |
item_id="$( gh api graphql --header 'GraphQL-Features: projects_next_graphql' -f query='
mutation($project:ID!, $id:ID!) {
addProjectNextItem(input: {projectId: $project, contentId: $id}) {
projectNextItem {
id
}
}
}' -f project=$PROJECT_ID -f id=$ISSUE_ID --jq '.data.addProjectNextItem.projectNextItem.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
uses: specklesystems/github-actions/.github/workflows/project-add-issue.yml@main
secrets: inherit
with:
issue-id: ${{ github.event.issue.node_id }}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+9 -1
View File
@@ -31,7 +31,12 @@
"in": "object"
}
}
]
],
"dataReductionAlgorithm": {
"top": {
"count": 30000
}
}
},
"values": {
"for": { "in": "objectData" }
@@ -43,6 +48,8 @@
"supportsMultiVisualSelection": true,
"suppressDefaultTitle": true,
"supportsSynchronizingFilterState": true,
"supportsLandingPage": true,
"supportsEmptyDataView": true,
"supportsKeyboardFocus": true,
"tooltips": {
"supportEnhancedTooltips": true
@@ -152,6 +159,7 @@
"https://*.speckle.xyz",
"https://latest.speckle.dev",
"https://*.speckle.dev",
"https://analytics.speckle.systems",
"*"
]
},
+2108 -275
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -15,13 +15,20 @@
"dependencies": {
"@babel/runtime": "7.6.0",
"@babel/runtime-corejs2": "7.6.0",
"@speckle/viewer": "^2.7.1",
"@speckle/viewer": "^2.11.4",
"color-interpolate": "^1.0.5",
"core-js": "3.2.1",
"powerbi-visuals-api": "~4.7.0",
"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"
},
"devDependencies": {
"@types/core-js": "^2.5.5",
"@types/lodash": "^4.14.188",
"@types/regenerator-runtime": "^0.13.1",
"ts-loader": "6.1.0",
"tslint": "^5.18.0",
"tslint-microsoft-contrib": "^6.2.0",
+2 -2
View File
@@ -4,12 +4,12 @@
"displayName": "Speckle PowerBI Viewer",
"guid": "powerbiSpeckleVisualAA98F06515D847E8ACB33BAB487244E0",
"visualClassName": "Visual",
"version": "2.0.0-alpha1",
"version": "2.0.0-alpha5",
"description": "An interactive 3D viewer for Speckle Data",
"supportUrl": "https://speckle.community",
"gitHubUrl": "https://github.com/specklesystems/speckle-powerbi-visuals"
},
"apiVersion": "4.7.0",
"apiVersion": "5.1.0",
"author": { "name": "Speckle Systems", "email": "info@speckle.systems" },
"assets": { "icon": "assets/logo.png" },
"externalJS": null,
+62
View File
@@ -0,0 +1,62 @@
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"
}
export enum SettingsChangedType {
Gradient = "Gradient",
DefaultCamera = "DefaultCamera",
OrthoMode = "OrthoMode"
}
export class Tracker {
public static async track(event: Event, properties: any = {}) {
return this.trackEvents([
{
event,
properties
}
])
}
private static async trackEvents(
events: Array<{ event: Event; properties: any }>
) {
try {
var res = await fetch(TRACK_URL, {
method: "POST",
body:
"data=" +
JSON.stringify(
events.map(e => {
Object.assign(e.properties, {
token: MIXPANEL_TOKEN,
hostApp: HOST_APP_NAME
})
return e
})
)
})
//console.log("Create track", res, await res.json())
} catch (e) {
console.error("Create track failed", e)
}
}
public static loaded() {
return this.track(Event.Create)
}
public static dataReload() {
return this.track(Event.Reload)
}
public static settingsChanged(type: SettingsChangedType) {
return this.track(Event.Settings, { type })
}
}
+61
View File
@@ -0,0 +1,61 @@
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
}
}
+355 -186
View File
@@ -3,95 +3,105 @@
import "core-js/stable"
import "regenerator-runtime/runtime" /* <---- add this line */
import "./../style/visual.less"
import powerbi from "powerbi-visuals-api"
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, DefaultViewerParams } from "@speckle/viewer"
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
}
export class Visual implements IVisual {
private target: HTMLElement
private settings: SpeckleVisualSettings
private host: powerbi.extensibility.IVisualHost
private selectionManager: powerbi.extensibility.ISelectionManager
private selectionIdMap: Map<string, any>
private tooltipService: ITooltipService
private selectionIdMap: Map<string, powerbi.extensibility.ISelectionId>
private viewer: Viewer
constructor(options: VisualConstructorOptions) {
console.log("Speckle 3D Visual constructor called", options)
this.host = options.host
private updateTask: Promise<void>
private ac = new AbortController()
private currentOrthoMode: boolean = false
private currentDefaultView: string = "default"
private currentTooltip: SpeckleTooltip = null
this.selectionIdMap = new Map<string, any>()
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
}
public async initViewer() {
if (this.viewer) {
console.log("Viewer was already initialized. Skipping init call...")
return
}
if (this.viewer) return
var container = this.target.appendChild(document.createElement("div"))
container.style.backgroundColor = "transparent"
container.style.height = "100%"
container.style.width = "100%"
container.style.position = "fixed"
var container = this.createContainerDiv()
const viewer = new Viewer(container)
await viewer.init()
const params = DefaultViewerParams
// Uncomment the line below to show stats
params.showStats = true
// 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
)
const viewer = new Viewer(container, params)
return viewer.init().then(() => {
viewer.onWindowResize()
viewer.on(
"load-progress",
(a: { progress: number; id: string; url: string }) => {
this.loadedUrls[a.url] = a.progress
if (a.progress >= 1) {
viewer.onWindowResize()
}
}
)
viewer.on("load-complete", () => {
//console.log("Load complete")
})
viewer.on("select", o => {
if (o.location == null) return
console.log("viewer object selected", o)
//var ids = o.userData.map(data => this.selectionIdMap[data.id])
// this.selectionManager.showContextMenu(ids[0] ?? {}, {
// x: rect.top + o.location.x,
// y: rect.left + o.location.y
// })
})
this.viewer = viewer
})
this.viewer = viewer
}
private loadedUrls = {}
public update(options: VisualUpdateOptions) {
this.settings = Visual.parseSettings(
options && options.dataViews && options.dataViews[0]
)
this.HandleLandingPage(options)
if (this.isLandingPageOn) return
console.log(
`Update was called with update type ${options.type.toString()}`,
`Update was called with update type ${VisualUpdateTypeToString(
options.type
)}`,
options,
this.settings
)
@@ -109,176 +119,155 @@ export class Visual implements IVisual {
}
console.log("Data was updated, updating viewer...")
this.initViewer().then(_ => {
// Handle changes in the visual objects
this.handleSettingsUpdate(options)
// Handle the update in data passed to this visual
return this.handleDataUpdate(options)
})
this.debounceUpdate(options)
}
private currentOrthoMode: boolean = undefined
private currentDefaultView: string = undefined
private handleSettingsUpdate(options: VisualUpdateOptions) {
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.interactions.rotateTo(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 handleDataUpdate(options: VisualUpdateOptions) {
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 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
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>()
return
}
console.log("Viewer loading:", options)
//@ts-ignore
var selectionBuilder = this.host.createSelectionIdBuilder()
var objectUrls = streamCategory.map(
(stream, index) => `${stream}/objects/${objectIdCategory[index]}`
)
var objectUrls = streamCategory.map((stream, index) => {
var url = `${stream}/objects/${objectIdCategory[index]}`
return url
})
var objectsToUnload = []
for (const key in this.selectionIdMap.keys()) {
if (!objectUrls.find(url => url.split("/").slice(-1).pop() == key)) {
for (const key of this.selectionIdMap.keys()) {
const found = objectUrls.find(url => url == key)
if (!found) {
objectsToUnload.push(key)
}
}
console.log(
`Viewer loading ${objectUrls.length} and unloading ${objectsToUnload.length}`
)
var unloadPromises = objectsToUnload.map(url => {
return this.viewer
.unloadObject(url)
for (const url of objectsToUnload) {
if (signal?.aborted) return
await this.viewer
.cancelLoad(url, true)
.then(_ => {
this.selectionIdMap.delete(url.split("/").slice(-1).pop())
this.selectionIdMap.delete(url)
})
.catch(e => console.warn("Viewer Unload error", url, e))
})
var loadPromises = objectUrls.map((url, index) => {
if (!this.selectionIdMap.has(url.split("/").slice(-1).pop())) {
var selectionId = selectionBuilder.withCategory(
categoricalView?.categories[1].values[index]
)
return this.viewer
.loadObject(url, null, false)
.then(_ => {
this.selectionIdMap.set(
categoricalView?.categories[1].values[index].toString(),
selectionId
)
})
.catch(e => {
console.warn("Viewer Load error", url, e)
})
}
})
var unloadRes = Promise.all(unloadPromises)
var loadRes = Promise.all(loadPromises)
return unloadRes
.then(_ => loadRes)
.then(_ => {
var colorList = this.settings.color.getColorList()
// Once everything is loaded, run the filter
var filter = null
if (categoricalView?.values) {
var name = categoricalView?.values[0].source.displayName
var isNum =
categoricalView?.values[0].source.type.numeric ||
categoricalView?.values[0].source.type.integer
var filterType = isNum ? "gradient" : "category"
console.log("filter:", filterType, name)
if (highlightedValues)
filter = {
filterBy: {
id: highlightedValues
.map((value, index) =>
value ? objectIdCategory[index] : null
)
.filter(e => e != null)
},
ghostOthers: true,
colorBy: {
type: filterType,
property: this.cleanupDataColumnName(name),
gradientColors: isNum ? colorList : undefined,
minValue: categoricalView?.values[0].minLocal,
maxValue: categoricalView?.values[0].maxLocal
}
}
else
filter = {
filterBy: {
id: objectIdCategory
},
colorBy: {
type: filterType,
property: this.cleanupDataColumnName(name),
gradientColors: isNum ? colorList : undefined,
minValue: categoricalView?.values[0].minLocal,
maxValue: categoricalView?.values[0].maxLocal
}
}
}
console.log("filter:", filter)
return this.viewer
.applyFilter(filter)
.catch(e => {
console.warn("Filter failed to be applied. Filter will be reset", e)
return this.viewer.applyFilter(null)
})
.then(_ => this.viewer.zoomExtents())
})
}
private 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]
console.log("clean name", cleanName)
return cleanName
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()
}
}
private static parseSettings(dataView: DataView): SpeckleVisualSettings {
return <SpeckleVisualSettings>SpeckleVisualSettings.parse(dataView)
}
@@ -296,4 +285,184 @@ export class Visual implements IVisual {
options
)
}
private debounceUpdate = _.debounce(options => {
this.initViewer().then(async _ => {
if (this.updateTask) {
this.ac.abort()
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)
)
})
}, 500)
private throttleCameraUpdate = _.throttle(options => {
if (!this.currentTooltip) return
var newScreenLoc = projectToScreen(
this.viewer.cameraHandler.camera,
this.currentTooltip.worldPos
)
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 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 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]
}
})
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
}
} else {
if (this.isLandingPageOn && !this.LandingPageRemoved) {
this.LandingPageRemoved = true
this.target.removeChild(this.LandingPage)
this.isLandingPageOn = false
}
}
}
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 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
}
})
}
}
+52 -8
View File
@@ -1,9 +1,53 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap");
p {
font-size: 20px;
font-weight: bold;
em {
background: yellow;
padding: 5px;
}
}
font-size: 15px;
font-family: "Space Grotesk", sans-serif;
}
.speckle-landing {
width: 100%;
height: 100%;
position: fixed;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.speckle-logo {
background-image: url("../assets/logo-blue-2.png");
background-repeat: no-repeat;
background-size: contain;
min-width: 200px;
min-height: 60px;
background-color: transparent;
}
.heading {
font-size: 20px;
font-weight: bold;
margin: 10px;
}
.instructions {
color: grey;
margin: 0;
}
.tip-container {
background-color: gainsboro;
border-radius: 10px;
padding: 10px;
}
.tip {
font-weight: bold;
font-style: italic;
margin: 0;
margin-bottom: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
+14 -18
View File
@@ -1,19 +1,15 @@
{
"compilerOptions": {
"allowJs": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es6",
"sourceMap": true,
"outDir": "./.tmp/build/",
"moduleResolution": "node",
"declaration": true,
"lib": [
"es2015",
"dom"
]
},
"files": [
"./src/visual.ts"
]
}
"compilerOptions": {
"allowJs": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es6",
"sourceMap": true,
"outDir": "./.tmp/build/",
"moduleResolution": "node",
"declaration": true,
"lib": ["es2015", "dom"],
"allowSyntheticDefaultImports": true
},
"files": ["./src/visual.ts"]
}