53 Commits

Author SHA1 Message Date
Iain Sproat 15d79e6606 feat(domains): add *.speckle.systems domains to capabilities (#57) 2024-08-12 11:44:44 +02:00
Mucahit Bilal GOKER bc5d16dfb8 Update README.md 2024-06-06 10:51:15 +03:00
Mucahit Bilal GOKER e445d27b01 Merge pull request #56 from specklesystems/bimgeek/update-readme
Update README.md
2024-06-04 17:01:23 +03:00
Mucahit Bilal GOKER 75c8a60cef Update README.md 2024-06-03 22:10:52 +03:00
Alan Rynne 0f034c17d0 bump: 2.19 2024-05-27 15:10:40 +02:00
Alan Rynne bded971ecf chore: Bump viewer version to latest (#54)
And bump visual version to `rc2`
2024-05-23 16:47:39 +02:00
Alan Rynne 77b13c2d89 bump: 2.19.0-rc 2024-05-14 18:37:20 +02:00
Alan Rynne e3855a71c1 Merge pull request #53 from specklesystems/dev
Update `main` with changes from `dev`
2024-05-14 17:21:50 +02:00
Alan Rynne 90c22211a9 bump to 2.19.0-wip (#52) 2024-04-29 12:24:51 +02:00
Alan Rynne b13eef0b18 bump: Viewer 2.18.15 + code changes (#51) 2024-04-23 14:25:09 +02:00
Alan Rynne 31c75f5407 Merge branch 'main' into dev 2024-04-08 16:31:53 +02:00
Benjamin Ottensten cd75dca5d7 Update web app link in README 2024-03-18 13:20:48 +01:00
Alan Rynne 72f1a836cb bump: 2.18.0 2024-03-14 11:42:49 +01:00
Alan Rynne 0232c91d42 Merge pull request #50 from specklesystems/CNX-9113-Power-BI-Visual-Update-to-use-new-FE2-Terminology
CNX-9113: FE2 -> fixed old naming in visual inputs and error message
2024-03-01 14:29:59 +01:00
Alan Rynne 38b2c55166 fixed old naming in visual inputs and error message 2024-03-01 13:36:36 +01:00
Alan Rynne fac7d8e547 bump: 2.17.0 2023-11-27 11:05:50 +01:00
Alan Rynne b19986ec35 Merge pull request #48 from specklesystems/dev
Update main from dev
2023-11-27 11:00:34 +01:00
Alan Rynne 3430cba29a Merge pull request #47 from specklesystems/alan/bump-2.16.0
Misc fixes and improvements for 2.17 release
2023-11-14 12:17:06 +01:00
Alan Rynne 6b57415d10 feat: Do not show Tooltip when no data is input 2023-11-12 23:26:56 +01:00
Alan Rynne 653dfb9910 fix: Selection when tooltip without color filter was active 2023-11-12 23:26:33 +01:00
Alan Rynne 30dbd19c52 feat: Adds watermark to loaded visual 2023-11-12 23:07:28 +01:00
Alan Rynne cfc958f9fd fix: do not upgrade ui dependencies yet 2023-11-10 10:49:10 +01:00
Alan Rynne 9cf6786e52 fix: Added images.d.ts 2023-10-31 12:43:59 +01:00
Alan Rynne 7ad8cd7e24 fix: Pack speckle logo with webpack correctly 2023-10-31 12:07:01 +01:00
Alan Rynne 71cbd55583 bump: Speckle dependencies to 2.16.0 2023-10-27 10:01:46 +02:00
Alan Rynne c3ca9c80dd Merge branch 'dev' into main 2023-07-07 14:41:25 +02:00
Alan Rynne 1966669a74 bump: Version 2.15.0-rc 2023-07-07 14:41:15 +02:00
Alan Rynne 12c7fbdf64 fix: Upgrade webpack plugin package and minor tweaks to webpack config (#40)
Fixes issue where PowerBI desktop reported the visual as invalid
2023-06-20 14:44:50 +02:00
Alan Rynne d7772ca558 Merge branch 'main' into dev 2023-06-19 17:03:15 +02:00
Alan Rynne cc0113b8f9 fix: Miscellaneous fixes (#39)
Fixes coloring issue when no tooltip data
Adds ghosted/hidden context setting
Minor tweaks on settings panel
Added clear color palette button (temporary)
Updates viewer to latest released version
2023-06-19 17:02:38 +02:00
garylzimmer 0e05ad8584 Update README.md (#38)
added note about expected use case and link to relevant github for data connector
2023-06-12 17:33:55 +02:00
Alan Rynne 1b55d39787 feat: New setting spanel using FormattingModel (#37)
* fix: Removed vue warning by setting env vars

* fix: Tweaks in prod webpack config

* chore: Removed unnecessary css classes

* chore: Simplified vue loader

* feat: Working formatting model

* feat: Settings panel with lighting and camera options

* feat: Working default color, gradient and rule based conditional formatting

* feat: Improvements in settings panel and related areas
2023-06-06 11:31:17 +02:00
Alan Rynne c7066c2242 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
2023-05-23 16:25:44 +02:00
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
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
54 changed files with 13207 additions and 2230 deletions
+37
View File
@@ -0,0 +1,37 @@
/** @type {import("eslint").Linter.Config} */
const config = {
root: true,
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
requireConfigFile: false,
ecmaVersion: 2020,
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:vue/vue3-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
-78
View File
@@ -1,78 +0,0 @@
name: Update issue Status
on:
issues:
types: [closed]
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 }}
-50
View File
@@ -1,50 +0,0 @@
name: Move new issues into Project
on:
issues:
types: [opened]
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
+4 -1
View File
@@ -3,4 +3,7 @@ dist/
webpack.statistics.dev.html
.tmp/
webpack.statistics.prod.html
.DS_Store
.DS_Store
.idea/
webpack.statistics.html
**/Thumbs.db
+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
}
+32 -37
View File
@@ -1,40 +1,35 @@
{
"editor.tabSize": 4,
"editor.insertSpaces": true,
"files.eol": "\n",
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/node_modules/**": true,
".tmp": true
"editor.tabSize": 4,
"editor.insertSpaces": true,
"files.eol": "\n",
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/node_modules/**": true,
".tmp": true
},
"files.exclude": {
".tmp": true
},
"files.associations": {
"*.resjson": "json"
},
"search.exclude": {
".tmp": true,
"typings": true
},
"json.schemas": [
{
"fileMatch": ["/pbiviz.json"],
"url": "./node_modules/powerbi-visuals-api/schema.pbiviz.json"
},
"files.exclude": {
".tmp": true
{
"fileMatch": ["/capabilities.json"],
"url": "./node_modules/powerbi-visuals-api/schema.capabilities.json"
},
"files.associations": {
"*.resjson": "json"
},
"search.exclude": {
".tmp": true,
"typings": true
},
"json.schemas": [
{
"fileMatch": [
"/pbiviz.json"
],
"url": "./node_modules/powerbi-visuals-api/schema.pbiviz.json"
},
{
"fileMatch": [
"/capabilities.json"
],
"url": "./node_modules/powerbi-visuals-api/schema.capabilities.json"
},
{
"fileMatch": [
"/dependencies.json"
],
"url": "./node_modules/powerbi-visuals-api/schema.dependencies.json"
}
]
}
{
"fileMatch": ["/dependencies.json"],
"url": "./node_modules/powerbi-visuals-api/schema.dependencies.json"
}
],
"vue.codeActions.enabled": false
}
+9 -2
View File
@@ -1,12 +1,19 @@
# ⚠️⚠️⚠️ Note: This repository is no longer maintained and has been merged with the [Speckle Power BI](https://github.com/specklesystems/speckle-powerbi) repository. ⚠️⚠️⚠️
The functionalities and features of Speckle Power BI Visuals have been consolidated into the main [Speckle Power BI](https://github.com/specklesystems/speckle-powerbi) repository. Please visit the Speckle Power BI repository for the latest updates, installation instructions, and continued development.
<h1 align="center">
<img src="https://user-images.githubusercontent.com/2679513/131189167-18ea5fe1-c578-47f6-9785-3748178e4312.png" width="150px"/><br/>
Speckle | PowerBI Visuals
</h1>
<h3 align="center">
3D Viewer for PowerBI and more...
Expected use case is that the visual displays data pulled from Speckle via the Speckle Data Connector for PowerBI (https://github.com/specklesystems/speckle-powerbi)
</h3>
> ⚠️ This repo is still in very early stages of development, use at your own risk!
> This repo is still in very early stages of development, use at your own risk!
<p align="center"><b>Speckle</b> is data infrastructure for the AEC industry.</p><br/>
@@ -34,7 +41,7 @@ What is Speckle? Check our ![YouTube Video Views](https://img.shields.io/youtube
Give Speckle a try in no time by:
- [![speckle XYZ](https://img.shields.io/badge/https://-speckle.xyz-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://speckle.xyz) ⇒ creating an account at
- [![speckle](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ creating an account
- [![create a droplet](https://img.shields.io/badge/Create%20a%20Droplet-0069ff?style=flat-square&logo=digitalocean&logoColor=white)](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
### Resources
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+141 -67
View File
@@ -1,29 +1,54 @@
{
"dataRoles": [
{
"displayName": "Stream URL",
"name": "stream",
"kind": "Grouping"
"displayName": "Model URL",
"kind": "Grouping",
"name": "stream"
},
{
"displayName": "Version 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": "Grouping",
"name": "objectColorBy"
},
{
"displayName": "Tooltip Data",
"kind": "Measure",
"name": "objectData"
}
],
"dataViewMappings": [
{
"categorical": {
"categories": {
"matrix": {
"rows": {
"dataReductionAlgorithm": {
"top": {
"count": 30000
}
},
"select": [
{
"for": {
"in": "stream"
"bind": {
"to": "stream"
}
},
{
"bind": {
"to": "parentObject"
}
},
{
"bind": {
"to": "objectColorBy"
}
},
{
@@ -34,79 +59,84 @@
]
},
"values": {
"for": { "in": "objectData" },
"dataReductionAlgorithm": {
"top": {
"count": 30000
"select": [
{
"bind": {
"to": "objectData"
}
}
}
]
}
}
}
],
"supportsHighlight": true,
"supportsMultiVisualSelection": true,
"suppressDefaultTitle": true,
"supportsSynchronizingFilterState": 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": {
"enumeration": [
{
"displayName": "Perspective",
"displayNameKey": "perspective",
"value": "perspective"
},
{
"displayName": "Top",
"displayNameKey": "top",
"value": "top"
},
{
"displayName": "Front",
"displayNameKey": "front",
"value": "front"
},
{
"displayName": "Left",
"displayNameKey": "left",
"value": "left"
},
{
"displayName": "Back",
"displayNameKey": "back",
"value": "back"
},
{
"displayName": "Right",
"displayNameKey": "right",
"value": "right"
}
]
}
},
"allowCameraUnder": {
"type": {
"bool": true
}
},
"zoomOnDataChange": {
"type": {
"bool": true
}
},
"projection": {
"type": {
"enumeration": [
{
"displayName": "Perspective",
"value": "perspective"
},
{
"displayName": "Orthographic",
"value": "orthographic"
}
]
}
}
}
},
"color": {
"displayName": "Color",
"properties": {
"startColor": {
"displayName": "Start Color",
"enabled": {
"type": {
"bool": true
}
},
"fill": {
"type": {
"fill": {
"solid": {
@@ -115,34 +145,57 @@
}
}
},
"midColor": {
"displayName": "Middle Color",
"context": {
"type": {
"fill": {
"solid": {
"color": true
"enumeration": [
{
"displayName": "Hidden",
"value": "hidden"
},
{
"displayName": "Ghosted",
"value": "ghosted"
}
}
]
}
}
}
},
"lighting": {
"properties": {
"enabled": {
"type": {
"bool": true
}
},
"endColor": {
"displayName": "End Color",
"intensity": {
"type": {
"fill": {
"solid": {
"color": true
}
}
"numeric": true
}
},
"background": {
"displayName": "Background Color",
"elevation": {
"type": {
"fill": {
"solid": {
"color": true
}
}
"numeric": true
}
},
"azimuth": {
"type": {
"numeric": true
}
},
"indirect": {
"type": {
"numeric": true
}
},
"shadows": {
"type": {
"bool": true
}
},
"shadowCatcher": {
"type": {
"bool": true
}
}
}
@@ -150,10 +203,12 @@
},
"privileges": [
{
"name": "WebAccess",
"essential": true,
"name": "WebAccess",
"parameters": [
"https://speckle.xyz",
"https://app.speckle.systems",
"https://latest.speckle.systems",
"https://*.speckle.xyz",
"https://latest.speckle.dev",
"https://*.speckle.dev",
@@ -162,8 +217,27 @@
]
},
{
"name": "ExportContent",
"essential": false
"essential": false,
"name": "ExportContent"
},
{
"essential": true,
"name": "LocalStorage",
"parameters": []
}
]
],
"sorting": {
"default": {}
},
"supportsEmptyDataView": true,
"supportsHighlight": true,
"supportsKeyboardFocus": true,
"supportsLandingPage": true,
"keepAllMetadataColumns": true,
"supportsMultiVisualSelection": true,
"supportsSynchronizingFilterState": true,
"suppressDefaultTitle": true,
"tooltips": {
"supportEnhancedTooltips": true
}
}
+10747 -1597
View File
File diff suppressed because it is too large Load Diff
+64 -21
View File
@@ -1,34 +1,77 @@
{
"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"
"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.6.0",
"@babel/runtime-corejs2": "7.6.0",
"@speckle/viewer": "2.7.1",
"core-js": "3.2.1",
"@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.7",
"@speckle/ui-components": "2.14.7",
"@speckle/viewer": "^2.18.14",
"color-interpolate": "^1.0.5",
"core-js": "^3.30.2",
"lodash": "^4.17.21",
"powerbi-visuals-api": "~4.7.0",
"powerbi-visuals-utils-dataviewutils": "2.4.1",
"regenerator-runtime": "^0.13.9"
"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-formattingmodel": "^5.0.0",
"powerbi-visuals-utils-interactivityutils": "^6.0.2",
"powerbi-visuals-utils-tooltiputils": "^6.0.1",
"regenerator-runtime": "^0.13.11",
"vuex": "^4.1.0"
},
"devDependencies": {
"@types/core-js": "^2.5.5",
"@types/lodash": "^4.14.188",
"@babel/core": "^7.21.8",
"@babel/eslint-parser": "^7.21.8",
"@babel/preset-env": "^7.21.5",
"@tailwindcss/forms": "^0.5.3",
"@types/lodash": "^4.14.194",
"@types/node": "^20.1.7",
"@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.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": "^4.0.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",
"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"
}
}
+4 -4
View File
@@ -4,16 +4,16 @@
"displayName": "Speckle PowerBI Viewer",
"guid": "powerbiSpeckleVisualAA98F06515D847E8ACB33BAB487244E0",
"visualClassName": "Visual",
"version": "2.0.0-alpha3",
"version": "2.19.0",
"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.4.0",
"author": { "name": "Speckle Systems", "email": "info@speckle.systems" },
"assets": { "icon": "assets/logo.png" },
"externalJS": null,
"style": "style/visual.less",
"externalJS": [],
"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>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import {
VideoCameraIcon,
CubeIcon,
ArrowsPointingOutIcon,
PaintBrushIcon
} 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'
import ButtonSimple from 'src/components/controls/ButtonSimple.vue'
import { inject, watch } from 'vue'
import { hostKey, viewerHandlerKey } from 'src/injectionKeys'
import { resetPalette } from 'src/utils/matrixViewUtils'
const emits = defineEmits(['update:sectionBox', 'view-clicked', 'clear-palette'])
const props = withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
sectionBox: false,
views: () => []
})
const viewerHandler = inject(viewerHandlerKey)
const canonicalViews = [
{ name: 'Top' },
{ name: 'Front' },
{ name: 'Left' },
{ name: 'Back' },
{ name: 'Right' }
]
const onZoomExtentsClicked = (ev: MouseEvent) => {
console.log('Zoom extents clicked', viewerHandler)
viewerHandler.zoomExtents()
}
const host = inject(hostKey)
const onClearPalletteClicked = (ev: MouseEvent) => {
console.log('Clear pallette clicked')
resetPalette()
emits('clear-palette')
}
</script>
<template>
<ButtonGroup>
<ButtonSimple flat secondary @click="onZoomExtentsClicked">
<ArrowsPointingOutIcon class="h-5 w-5" />
</ButtonSimple>
<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>
<ButtonSimple flat secondary @click="onClearPalletteClicked">
<PaintBrushIcon class="h-5 w-5" />
</ButtonSimple>
</ButtonGroup>
</template>
<style scoped></style>
+184
View File
@@ -0,0 +1,184 @@
<script async setup lang="ts">
import {
computed,
inject,
onBeforeUnmount,
onMounted,
provide,
Ref,
ref,
watch,
watchEffect
} from 'vue'
import { useStore } from 'vuex'
import ViewerControls from 'src/components/ViewerControls.vue'
import { CanonicalView, 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,
viewerHandlerKey
} from 'src/injectionKeys'
import { SpeckleDataInput } from 'src/types'
import { debounce, throttle } from 'lodash'
import { ContextOption } from 'src/settings/colorSettings'
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 settings = computed(() => store.state.settings)
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)
provide<ViewerHandler>(viewerHandlerKey, viewerHandler)
setupTask = viewerHandler
.init()
.then(() => viewerHandler.addCameraUpdateEventListener(onCameraMoved))
.finally(async () => {
if (input.value) await cancelAndHandleDataUpdate()
viewerHandler.updateSettings(settings.value)
})
})
onBeforeUnmount(async () => {
await viewerHandler.dispose()
})
const debounceUpdate = throttle(cancelAndHandleDataUpdate, 500)
const debounceSettingsUpdate = throttle(() => viewerHandler.updateSettings(settings.value), 500)
watch(input, debounceUpdate)
watch(settings, debounceSettingsUpdate)
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
)
// Color
await viewerHandler.colorObjectsByGroup(input.value.colorByIds)
await viewerHandler.unIsolateObjects()
const objectsToIsolate =
input.value.selectedIds.length == 0 ? input.value.objectIds : input.value.selectedIds
if (settings.value.color.context.value != ContextOption.show)
await viewerHandler.isolateObjects(
objectsToIsolate,
settings.value.color.context.value === ContextOption.ghosted
)
if (settings.value.camera.zoomOnDataChange.value) viewerHandler.zoom(objectsToIsolate)
// 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('New input is available')
console.log('Cancelling previous load job')
await updateTask.value
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)
}
function onClearPalette() {
cancelAndHandleDataUpdate()
}
</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)"
@clearPalette="onClearPalette"
/>
</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>
+45
View File
@@ -0,0 +1,45 @@
<template>
<button
ref="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, onBeforeUnmount, onMounted, ref } from 'vue'
let active = ref(false)
let button = ref<HTMLElement>()
const props = defineProps<{
flat?: boolean
secondary?: boolean
}>()
const shadowClasses = computed(() => (props.flat ? '' : 'shadow-md'))
const colorClasses = computed(() => {
const parts = []
if (active.value) {
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(' ')
})
const onPointerDown = () => (active.value = true)
const onPointerUp = () => (active.value = false)
onMounted(() => {
button.value.addEventListener('pointerdown', onPointerDown)
button.value.addEventListener('pointerup', onPointerUp)
})
onBeforeUnmount(() => {
button.value.removeEventListener('pointerdown', onPointerDown)
button.value.removeEventListener('pointerup', onPointerUp)
})
</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 }
}
+61
View File
@@ -0,0 +1,61 @@
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 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)
}
}
+53
View File
@@ -0,0 +1,53 @@
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
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)
if (Object.keys(tooltipData.dataItems).length > 0) this.tooltipService.show(tooltipData)
}
public hide() {
this.tooltipService.hide({ immediately: true, isTouchEvent: false })
this.currentTooltip = null
}
public move(pos: { x: number; y: number }) {
if (!this.currentTooltip) return
this.currentTooltip.tooltip.coordinates = [pos.x, pos.y]
this.tooltipService.move(this.currentTooltip.tooltip)
}
}
+226
View File
@@ -0,0 +1,226 @@
import {
CanonicalView,
FilteringState,
LegacyViewer,
IntersectionQuery,
DefaultViewerParams,
Box3,
SpeckleView,
CameraController
} from '@speckle/viewer'
import { pickViewableHit, projectToScreen } from '../utils/viewerUtils'
import _ from 'lodash'
import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
export default class ViewerHandler {
private viewer: LegacyViewer
private readonly parent: HTMLElement
private state: FilteringState
private loadedObjectsCache: Set<string> = new Set<string>()
private config = {
authToken: null,
batchSize: 25
}
private currentSectionBox: Box3 = null
private currentSettings: SpeckleVisualSettingsModel
public getViews() {
return this.viewer.getViews()
}
public updateSettings(settings: SpeckleVisualSettingsModel) {
// Camera settings
switch (settings.camera.projection.value) {
case 'perspective':
this.viewer.setPerspectiveCameraOn()
break
case 'orthographic':
this.viewer.setOrthoCameraOn()
break
}
this.viewer.getExtension(CameraController).controls.maxPolarAngle = settings.camera
.allowCameraUnder.value
? Math.PI
: Math.PI / 2
// Lighting settings
const newConfig = settings.lighting.getViewerConfiguration()
this.viewer.setLightConfiguration(newConfig)
this.currentSettings = settings
}
public setView(view: SpeckleView | CanonicalView) {
this.viewer.setView(view)
}
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.getExtension(CameraController).controls.addEventListener('update', listener)
}
public constructor(parent: HTMLElement) {
this.parent = parent
}
public async init() {
if (this.viewer) return
const viewerSettings = DefaultViewerParams
viewerSettings.showStats = false
viewerSettings.verbose = false
const viewer = new LegacyViewer(this.parent, viewerSettings)
await viewer.init()
this.viewer = viewer
}
public async unloadObjects(
objects: string[],
signal?: AbortSignal,
onObjectUnloaded?: (url: string) => void
) {
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
) {
try {
let index = 0
let promises = []
for (const url of objectUrls) {
signal.throwIfAborted()
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.config.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.config.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) {
if (error.name === 'AbortError') return
throw new Error(`Load objects failed: ${error}`)
}
}
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 zoom(objectIds?: string[]) {
this.viewer.zoom(objectIds)
}
public zoomExtents() {
this.viewer.zoom()
}
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
}[]
) {
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) {
if (!this.viewer) return
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.getExtension(CameraController).controls.camera,
worldPosition
)
}
public dispose() {
this.viewer.getExtension(CameraController).controls.removeAllEventListeners()
this.viewer.dispose()
this.viewer = null
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.png' {
const source: string
export default source
}
+12
View File
@@ -0,0 +1,12 @@
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'
import ViewerHandler from 'src/handlers/viewerHandler'
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()
export const viewerHandlerKey: InjectionKey<ViewerHandler> = Symbol()
+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]
}
}
+35
View File
@@ -0,0 +1,35 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
export class CameraSettings extends fs.Card {
public defaultView: fs.SimpleSlice = new fs.AutoDropdown({
name: 'defaultView',
displayName: 'Default View',
value: 'perspective'
})
public projection = new fs.AutoDropdown({
name: 'projection',
displayName: 'Projection',
value: 'perspective'
})
public allowCameraUnder = new fs.ToggleSwitch({
name: 'allowCameraUnder',
displayName: 'Allow under model',
value: false
})
public zoomOnDataChange = new fs.ToggleSwitch({
name: 'zoomOnDataChange',
displayName: 'Zoom extent on change',
value: true
})
name = 'camera'
displayName = 'Camera'
slices: fs.Slice[] = [
this.defaultView,
this.projection,
this.allowCameraUnder,
this.zoomOnDataChange
]
}
+51
View File
@@ -0,0 +1,51 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
import {
createDataViewWildcardSelector,
DataViewWildcardMatchingOption
} from 'powerbi-visuals-utils-dataviewutils/lib/dataViewWildcard'
import VisualEnumerationInstanceKinds = powerbi.VisualEnumerationInstanceKinds
export enum ContextOption {
hidden = 'hidden',
ghosted = 'ghosted',
show = 'show'
}
export class ColorSettings extends fs.Card {
public enabled = new fs.ToggleSwitch({
name: 'enabled',
displayName: 'Enabled',
value: true,
topLevelToggle: true
})
public fill = new fs.ColorPicker({
name: 'fill',
displayName: 'Color override',
description:
'Allows to override the colors of each object based on user-defined rules. Default color does not affect visualization.',
value: { value: '#c5c5c5' },
defaultColor: { value: '#c5c5c5' },
selector: createDataViewWildcardSelector(DataViewWildcardMatchingOption.InstancesAndTotals),
altConstantSelector: {
static: {}
},
instanceKind: VisualEnumerationInstanceKinds.ConstantOrRule
})
public context = new fs.AutoDropdown({
name: 'context',
displayName: 'Context display',
description: 'Determines how to display objects not present in the input data table.',
value: ContextOption.ghosted
})
name = 'color'
displayName = 'Object Display'
slices: fs.Slice[] = [this.context, this.fill]
}
export class ColorSelectorSettings extends fs.Card {
name = 'colorSelector'
displayName = 'Color Selector'
slices = []
}
+85
View File
@@ -0,0 +1,85 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
import ValidatorType = powerbi.visuals.ValidatorType
import { SunLightConfiguration } from '@speckle/viewer'
export class LightingSettings extends fs.Card {
name = 'lighting'
displayName = 'Lighting'
public enabled = new fs.ToggleSwitch({
name: 'enabled',
displayName: 'Enabled',
value: true,
topLevelToggle: true
})
public intensity = new fs.Slider({
name: 'intensity',
displayName: 'Intensity',
options: {
minValue: { type: ValidatorType.Min, value: 1 },
maxValue: { type: ValidatorType.Max, value: 10 }
},
value: 5
})
public elevation = new fs.Slider({
name: 'elevation',
displayName: 'Elevation',
options: {
minValue: { type: ValidatorType.Min, value: 0 },
maxValue: { type: ValidatorType.Max, value: Math.PI }
},
value: 1.33
})
public azimuth = new fs.Slider({
name: 'azimuth',
displayName: 'azimuth',
options: {
minValue: { type: ValidatorType.Min, value: -Math.PI * 0.5 },
maxValue: { type: ValidatorType.Max, value: Math.PI * 0.5 }
},
value: 0.75
})
public indirect = new fs.Slider({
name: 'indirect',
displayName: 'indirect',
options: {
minValue: { type: ValidatorType.Min, value: 0.0 },
maxValue: { type: ValidatorType.Max, value: 5.0 }
},
value: 1.2
})
public shadows = new fs.ToggleSwitch({
name: 'shadows',
displayName: 'Cast shadows',
value: true
})
public shadowCatcher = new fs.ToggleSwitch({
name: 'shadowCatcher',
displayName: 'Catch Shadows',
value: true
})
slices: fs.Slice[] = [
this.intensity,
this.elevation,
this.azimuth,
this.indirect,
this.shadows,
this.shadowCatcher
]
public getViewerConfiguration(): SunLightConfiguration {
return {
enabled: this.enabled.value,
castShadow: this.shadows.value,
intensity: this.intensity.value,
elevation: this.elevation.value,
azimuth: this.azimuth.value,
indirectLightIntensity: this.intensity.value,
shadowcatcher: this.shadowCatcher.value
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
import { ColorSelectorSettings, ColorSettings } from 'src/settings/colorSettings'
import { CameraSettings } from 'src/settings/cameraSettings'
import { LightingSettings } from 'src/settings/lightingSettings'
export class SpeckleVisualSettingsModel extends fs.Model {
// Building my visual formatting settings card
public color: ColorSettings = new ColorSettings()
public colorSelector: ColorSelectorSettings = new ColorSelectorSettings()
public camera: CameraSettings = new CameraSettings()
public lighting: LightingSettings = new LightingSettings()
cards = [this.color, this.camera, this.lighting]
}
+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
}
+41
View File
@@ -0,0 +1,41 @@
import { createStore } from 'vuex'
import { SpeckleDataInput } from 'src/types'
import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
export type InputState = 'valid' | 'incomplete' | 'invalid'
export interface SpeckleVisualState {
input?: SpeckleDataInput
status: InputState
settings: SpeckleVisualSettingsModel
}
// Create a new store instance.
export const store = createStore<SpeckleVisualState>({
state() {
return {
input: null,
status: 'incomplete',
settings: null
}
},
mutations: {
setInput(state, input?: SpeckleDataInput) {
state.input = input
},
setStatus(state, status: InputState) {
state.status = status ?? 'invalid'
},
setSettings(state, settings: SpeckleVisualSettingsModel) {
state.settings = settings
},
clearInput(state) {
state.input = null
}
},
actions: {
update(context, status: InputState, input?: SpeckleDataInput) {
context.commit('setInput', input)
context.commit('setStatus', status)
}
}
})
+19
View File
@@ -0,0 +1,19 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
export interface IViewerTooltipData {
displayName: string
value: string
}
export interface IViewerTooltip {
selectionId: powerbi.extensibility.ISelectionId
data: IViewerTooltipData[]
}
export interface SpeckleDataInput {
objectsToLoad: string[]
objectIds: string[]
selectedIds: string[]
colorByIds: { objectIds: string[]; slice: fs.ColorPicker; color: string }[]
objectTooltipData: Map<string, IViewerTooltip>
view: powerbi.DataViewMatrix
}
-51
View File
@@ -1,51 +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
}
+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
}
+223
View File
@@ -0,0 +1,223 @@
import powerbi from 'powerbi-visuals-api'
import { IViewerTooltip, IViewerTooltipData, SpeckleDataInput } from '../types'
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
import {
createDataViewWildcardSelector,
DataViewWildcardMatchingOption
} from 'powerbi-visuals-utils-dataviewutils/lib/dataViewWildcard'
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions
import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
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
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
}
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
): {
data: IViewerTooltipData[]
shouldColor: boolean
shouldSelect: boolean
id: string
selectionId: powerbi.visuals.ISelectionId
color?: string
} {
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
const objectValues = processObjectValues(objectIdChild, matrixView)
const res = { id: objId, selectionId: nodeSelection, color: undefined, ...objectValues }
// Process node objects, if any.
if (objectIdChild.objects) {
//@ts-ignore
const color = objectIdChild.objects.color.fill.solid.color as string
console.log('⚠️ HAS objects', color)
if (color) {
res.color = color
res.shouldColor = true
}
}
return res
}
function processObjectIdLevel(
parentObjectIdChild: powerbi.DataViewMatrixNode,
host: powerbi.extensibility.visual.IVisualHost,
matrixView: powerbi.DataViewMatrix
) {
return parentObjectIdChild.children?.map((objectIdChild) =>
processObjectNode(objectIdChild, host, matrixView)
)
}
export let previousPalette = null
export function resetPalette() {
previousPalette = null
}
export function processMatrixView(
matrixView: powerbi.DataViewMatrix,
host: powerbi.extensibility.visual.IVisualHost,
hasColorFilter: boolean,
settings: SpeckleVisualSettingsModel,
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) {
processObjectIdLevel(parentObjectIdChild, host, matrixView).forEach((objRes) => {
objectIds.push(objRes.id)
onSelectionPair(objRes.id, objRes.selectionId)
if (objRes.shouldSelect) selectedIds.push(objRes.id)
if (objRes.color) {
let group = colorByIds.find((g) => g.color === objRes.color)
if (!group) {
group = {
color: objRes.color,
objectIds: []
}
colorByIds.push(group)
}
group.objectIds.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 colorSelectionId = host
.createSelectionIdBuilder()
.withMatrixNode(colorByChild, matrixView.rows.levels)
.createSelectionId()
const color = host.colorPalette.getColor(colorByChild.value as string)
if (colorByChild.objects) {
console.log(
'⚠️COLOR NODE HAS objects',
colorByChild.objects,
colorByChild.objects.color?.fill
)
}
const colorSlice = new fs.ColorPicker({
name: 'selectorFill',
displayName: colorByChild.value.toString(),
value: {
value: color.value
},
selector: colorSelectionId.getSelector()
})
const colorGroup = {
color: color.value,
slice: colorSlice,
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)
}
}
+67
View File
@@ -0,0 +1,67 @@
import powerbiVisualsApi from 'powerbi-visuals-api'
import powerbi = powerbiVisualsApi
import DataViewObject = powerbi.DataViewObject
import DataViewObjects = powerbi.DataViewObjects
import DataViewCategoryColumn = powerbi.DataViewCategoryColumn
/**
* Gets property value for a particular object.
*
* @function
* @param {DataViewObjects} objects - Map of defined objects.
* @param {string} objectName - Name of desired object.
* @param {string} propertyName - Name of desired property.
* @param {T} defaultValue - Default value of desired property.
*/
export function getValue<T>(
objects: DataViewObjects,
objectName: string,
propertyName: string,
defaultValue: T
): T {
if (objects) {
const object = objects[objectName]
if (object) {
const property: T = <T>object[propertyName]
if (property !== undefined) {
return property
}
}
}
return defaultValue
}
/**
* Gets property value for a particular object in a category.
*
* @function
* @param {DataViewCategoryColumn} category - List of category objects.
* @param {number} index - Index of category object.
* @param {string} objectName - Name of desired object.
* @param {string} propertyName - Name of desired property.
* @param {T} defaultValue - Default value of desired property.
*/
export function getCategoricalObjectValue<T>(
category: DataViewCategoryColumn,
index: number,
objectName: string,
propertyName: string,
defaultValue: T
): T {
const categoryObjects = category.objects
if (categoryObjects) {
const categoryObject: DataViewObject = categoryObjects[index]
if (categoryObject) {
const object = categoryObject[objectName]
if (object) {
const property: T = <T>object[propertyName]
if (property !== undefined) {
return property
}
}
}
}
return defaultValue
}
+63
View File
@@ -0,0 +1,63 @@
// 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
}
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { FormButton } from '@speckle/ui-components'
import { inject } from 'vue'
import { hostKey } from 'src/injectionKeys'
const host = inject(hostKey)
function goToForum() {
host.launchUrl('https://speckle.community/tag/powerbi')
}
function goToGuide() {
host.launchUrl('https://speckle.guide/user/powerbi')
}
</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/3" />
</div>
<p class="heading">Speckle PowerBI 3D Visual</p>
<div class="flex justify-center mt-2 gap-1">
<FormButton color="invert" @click="goToForum">Help</FormButton>
<FormButton color="invert" @click="goToGuide">Getting started</FormButton>
</div>
</div>
</template>
<style scoped></style>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import ViewerWrapper from 'src/components/ViewerWrapper.vue'
import { inject } from 'vue'
import { hostKey } from 'src/injectionKeys'
const host = inject(hostKey)
const goToSpeckleWebsite = () => host.launchUrl('https://speckle.systems')
</script>
<template>
<div id="overlay">
<img class="watermark" src="@assets/powered-by-speckle.png" @click="goToSpeckleWebsite" />
</div>
<viewer-wrapper id="speckle-3d-view" class="h-full w-full"></viewer-wrapper>
</template>
<style scoped>
.watermark {
height: auto;
width: 60pt;
margin-top: 3pt;
margin-right: 3pt;
}
.watermark:hover {
cursor: pointer;
}
#overlay {
position: absolute;
width: 100%;
z-index: 100;
}
</style>
+93 -243
View File
@@ -1,277 +1,127 @@
"use strict"
import 'core-js/stable'
import 'regenerator-runtime/runtime'
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 { SpeckleVisualSettingsModel } from './settings/visualSettingsModel'
import TooltipHandler from './handlers/tooltipHandler'
import SelectionHandler from './handlers/selectionHandler'
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 IVisual = powerbi.extensibility.visual.IVisual
import EnumerateVisualObjectInstancesOptions = powerbi.EnumerateVisualObjectInstancesOptions
import VisualObjectInstance = powerbi.VisualObjectInstance
import DataView = powerbi.DataView
import VisualObjectInstanceEnumerationObject = powerbi.VisualObjectInstanceEnumerationObject
import ITooltipService = powerbi.extensibility.ITooltipService
import {
createDataViewWildcardSelector,
DataViewWildcardMatchingOption
} from 'powerbi-visuals-utils-dataviewutils/lib/dataViewWildcard'
import { ColorSelectorSettings } from 'src/settings/colorSettings'
import { SpeckleVisualSettings } from "./settings"
import { Viewer, DefaultViewerParams } from "@speckle/viewer"
import * as _ from "lodash"
import { VisualUpdateTypeToString, cleanupDataColumnName } from "./utils"
import { SettingsChangedType, Tracker } from "./mixpanel"
// noinspection JSUnusedGlobalSymbols
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 viewer: Viewer
private readonly host: powerbi.extensibility.visual.IVisualHost
private selectionHandler: SelectionHandler
private tooltipHandler: TooltipHandler
private updateTask: Promise<void>
private ac = new AbortController()
private currentOrthoMode: boolean = false
private currentDefaultView: string = "default"
private formattingSettings: SpeckleVisualSettingsModel
private formattingSettingsService: FormattingSettingsService
private debounceWait = 500
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)
)
})
}, this.debounceWait)
constructor(options: VisualConstructorOptions) {
// noinspection JSUnusedGlobalSymbols
public constructor(options: VisualConstructorOptions) {
Tracker.loaded()
this.host = options.host
this.formattingSettingsService = new FormattingSettingsService()
this.selectionIdMap = new Map<string, any>()
//@ts-ignore
this.selectionManager = this.host.createSelectionManager()
console.log('🚀 Init handlers')
this.selectionHandler = new SelectionHandler(this.host)
this.tooltipHandler = new TooltipHandler(this.host.tooltipService as ITooltipService)
this.target = options.element
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)
}
public async initViewer() {
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"
const params = DefaultViewerParams
const viewer = new Viewer(container, params)
await viewer.init()
// Setup any events here (progress, load-complete...)
this.viewer = viewer
private async clear() {
this.selectionHandler.clear()
}
public update(options: VisualUpdateOptions) {
this.settings = Visual.parseSettings(
options && options.dataViews && options.dataViews[0]
// @ts-ignore
console.log('⤴️ Update type 👉', powerbi.VisualUpdateType[options.type])
this.formattingSettings = this.formattingSettingsService.populateFormattingSettingsModel(
SpeckleVisualSettingsModel,
options.dataViews
)
console.log(
`Update was called with update type ${VisualUpdateTypeToString(
options.type
)}`,
options,
this.settings
)
console.log('Selector colors', this.formattingSettings.colorSelector)
let validationResult: { hasColorFilter: boolean; view: powerbi.DataViewMatrix } = null
try {
console.log('🔍 Validating input...', options)
validationResult = validateMatrixView(options)
console.log('✅Input valid', validationResult)
} catch (e) {
console.log('❌Input not valid:', (e as Error).message)
this.host.displayWarningIcon(
`Incomplete data input.`,
`"Model URL", "Version Object ID" and "Object ID" data inputs are mandatory. If your data connector does not output all these columns, please update it.`
)
console.warn(
`Incomplete data input. "Model URL", "Version Object ID" and "Object ID" data inputs are mandatory. If your data connector does not output all these columns, please update it.`
)
store.commit('setStatus', 'incomplete')
return
}
// 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.interactions.rotateTo(this.settings.camera.defaultView)
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
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
}
//@ts-ignore
var selectionBuilder = this.host.createSelectionIdBuilder()
var objectUrls = streamCategory.map((stream, index) => {
var url = `${stream}/objects/${objectIdCategory[index]}`
return url
})
var objectsToUnload = []
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}`
)
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
for (const url of objectUrls) {
if (signal?.aborted) return
if (!this.selectionIdMap.has(url)) {
var selectionId = selectionBuilder.withCategory(
categoricalView?.categories[1].values[index]
)
await this.viewer
.loadObject(url, null, false)
.then(_ => {
var url =
categoricalView?.categories[0].values[index].toString() +
"/objects/" +
categoricalView?.categories[1].values[index].toString()
this.selectionIdMap.set(url, selectionId)
})
.catch(e => {
console.warn("Viewer Load error", url, e)
})
}
index++
}
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"
if (highlightedValues)
filter = {
filterBy: {
id: highlightedValues
.map((value, index) => (value ? objectIdCategory[index] : null))
.filter(e => e != null)
},
ghostOthers: true,
colorBy: {
type: filterType,
property: 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: cleanupDataColumnName(name),
gradientColors: isNum ? colorList : undefined,
minValue: categoricalView?.values[0].minLocal,
maxValue: categoricalView?.values[0].maxLocal
}
default:
try {
const input = processMatrixView(
validationResult.view,
this.host,
validationResult.hasColorFilter,
this.formattingSettings,
(obj, id) => this.selectionHandler.set(obj, id)
)
this.throttleUpdate(input)
} catch (error) {
console.error('Data update error', error ?? 'Unknown')
}
}
if (signal?.aborted) return
Tracker.dataReload()
console.log("Applying filter:", filter)
return await 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())
}
public getFormattingModel(): powerbi.visuals.FormattingModel {
console.log('Showing Formatting settings', this.formattingSettings)
const model = this.formattingSettingsService.buildFormattingModel(this.formattingSettings)
console.log('Formatting model was created', model)
return model
}
private static parseSettings(dataView: DataView): SpeckleVisualSettings {
return <SpeckleVisualSettings>SpeckleVisualSettings.parse(dataView)
}
private throttleUpdate = _.throttle((input: SpeckleDataInput) => {
this.tooltipHandler.setup(input.objectTooltipData)
store.commit('setInput', input)
store.commit('setStatus', 'valid')
store.commit('setSettings', this.formattingSettings)
}, 500)
/**
* 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
)
public async destroy() {
await this.clear()
}
}
+4
View File
@@ -0,0 +1,4 @@
@import '@speckle/ui-components/style.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
-9
View File
@@ -1,9 +0,0 @@
p {
font-size: 20px;
font-weight: bold;
em {
background: yellow;
padding: 5px;
}
}
+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]
};
+28 -17
View File
@@ -1,19 +1,30 @@
{
"compilerOptions": {
"allowJs": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es2020",
"sourceMap": true,
"outDir": "./.tmp/build/",
"moduleResolution": "node",
"declaration": true,
"lib": ["es2020", "dom"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"types": [],
"baseUrl": ".",
"paths": {
"@src/*": ["src/*"],
"@assets/*": ["assets/*"]
}
},
"ts-node": {
"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"
]
}
"module": "CommonJS",
"esModuleInterop": true
}
},
"files": ["./src/visual.ts"],
"include": ["./src/**/*.ts", "./src/**/*.vue"],
"exclude": ["webpack.config.dev.ts"]
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "tslint-microsoft-contrib/recommended",
"rulesDirectory": [
"node_modules/tslint-microsoft-contrib"
],
"rules": {
"no-relative-imports": false
}
}
+225
View File
@@ -0,0 +1,225 @@
/* 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: 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: ['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/')
},
plugins: [new TsconfigPathsPlugin()]
},
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 webpack.DefinePlugin({
__VUE_OPTIONS_API__: JSON.stringify(true),
__VUE_PROD_DEVTOOLS__: JSON.stringify(false)
}),
new VueLoaderPlugin(),
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
+209
View File
@@ -0,0 +1,209 @@
/* 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: 'production',
module: {
rules: [
{
test: /\.vue$/,
use: ['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/')
},
plugins: [new TsconfigPathsPlugin()]
},
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
},
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 webpack.DefinePlugin({
__VUE_OPTIONS_API__: JSON.stringify(true),
__VUE_PROD_DEVTOOLS__: JSON.stringify(false)
}),
new VueLoaderPlugin(),
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,
minifyJS: true,
minify: 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'
})
]
}
export default config