Compare commits

...

85 Commits

Author SHA1 Message Date
Jedd Morgan 9700c993a2 workflow names 2025-04-30 17:49:04 +03:00
Jedd Morgan b95c833fb9 Pr CI 2025-04-30 17:43:26 +03:00
Dogukan Karatas 2da1602986 Merge pull request #146 from specklesystems/dogukan/cnx-1502-merge-tables-of-federated-models
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
feat: federated models
2025-04-30 16:18:44 +02:00
Dogukan Karatas a0372e1970 Merge pull request #149 from specklesystems/alex/no-more-legacy-viewer
Alex/no more legacy viewer
2025-04-30 16:17:39 +02:00
oguzhankoral 98f10bb344 Sort the new viewer after Alex and compression for federated models 2025-04-29 17:08:27 +03:00
oguzhankoral ee11e47af3 Merge remote-tracking branch 'origin/alex/no-more-legacy-viewer' into dogukan/cnx-1502-merge-tables-of-federated-models 2025-04-29 14:25:04 +03:00
AlexandruPopovici f57697d929 Got rid of LegacyViewer and adjusted the wrapper accordingly. Blindly 2025-04-29 10:25:07 +03:00
Jedd Morgan ac0db18d24 refactor(ci): Update workflow to use new consolidated deployment workflow (#148)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
* Update workflow

* target main
2025-04-24 15:09:32 +02:00
Dogukan Karatas 24d26dc49f adds support for federated models 2025-03-27 14:02:20 +01:00
Oğuzhan Koral 1fff59de85 Add flag for anonymous (#145)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-03-26 15:34:56 +03:00
Dogukan Karatas 771e34f6b5 null checks for anon user (#144) 2025-03-26 15:15:48 +03:00
Oğuzhan Koral f0b072d210 Feat(visual): persistent camera position (#143)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
* Subscribe the camera position

* Remember the last camera position

* Add email to mixpanel track

* Add custom views
2025-03-21 18:22:38 +03:00
Oğuzhan Koral 97a72c966a Feat(visual): select default view to load in powerbi (#141)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
* Add view modes and persist all camera settings into file

* Adjust z index

* sizing
2025-03-21 02:47:51 +03:00
Oğuzhan Koral 058c846770 Feat(visual): cleanup data (#140)
* cleanup data before loading to viewer

* only cleanup to properties and closures, good enough
2025-03-21 01:29:06 +03:00
Dogukan Karatas 3bb69f7aa8 removes beta flag (#139) 2025-03-19 14:59:37 +03:00
Dogukan Karatas c1dac7e663 Merge pull request #138 from specklesystems/dogukan/update-object-filtering
fix (data connector): object filtering logic
2025-03-19 10:39:35 +01:00
Dogukan Karatas 056815dad8 updated logic for filtering 2025-03-19 10:37:50 +01:00
Dogukan Karatas 929f06dbb7 update object filtering 2025-03-18 15:05:44 +01:00
Jedd Morgan 8cdfa97f41 Update build_powerbi.yml (#137) 2025-03-14 12:44:50 +03:00
Dogukan Karatas 28e9c43e5e fixes sourceApplication record (#136) 2025-03-03 20:30:31 +03:00
Oğuzhan Koral 380c60281b add ui property to track (#135)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-02-27 19:25:58 +03:00
Dogukan Karatas c4f7c6082e adds helper functions (#131)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-02-26 17:14:03 +00:00
Oğuzhan Koral 51a5261f1d more states.... (#134) 2025-02-26 16:53:28 +03:00
Oğuzhan Koral 5f6a032bf8 Fix: aligning colors across visuals (#133)
* Group by child value

* remove unused code
2025-02-26 15:04:05 +03:00
Jedd Morgan 8e1fe78af3 Remove desktop service from powerbi CI (#132) 2025-02-25 12:35:04 +00:00
Oğuzhan Koral 6dffa7dfa3 Feat(mixpanel): get user info from service (#130)
* get receive info from service

* rename userInfo to receiveInfo

* hijack download with more data

* gets email and token

* note for private project tests

---------

Co-authored-by: Dogukan Karatas <karatasdogukan@gmail.com>
2025-02-24 14:37:00 +03:00
Dogukan Karatas f61fae4284 feat (data): send user info to the service (#123)
* sends user info the service

* adds root object id

* fix port and casing

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-02-21 16:14:13 +03:00
Oğuzhan Koral f97946a426 Update build_powerbi.yml (#129)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-02-19 23:26:20 +03:00
Dogukan Karatas 8949917f21 fix(visual): update viewer version (#128)
* updates viewer version

* make it consistent
2025-02-19 23:03:34 +03:00
Oğuzhan Koral 0a0abd70df Chore: update ports (#127)
* update port

* update port +1
2025-02-19 12:51:02 +03:00
Oğuzhan Koral 19c9252a4a Update build_powerbi.yml (#126)
* Update build_powerbi.yml

* bump version
2025-02-19 12:50:44 +03:00
Oğuzhan Koral e50b04b305 Feat(visual): UI fine tuning (#125)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
* home view

* Update logo on viewer view

* progress bar while data is loading from bg service

* make home view speckle icon smaller

* put more gap between logo and speckle text
2025-02-17 22:19:02 +03:00
Dogukan Karatas 5cd0fe2b12 Merge pull request #124 from specklesystems/dogukan/visual-input-field-update
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
fix: rename the input field
2025-02-17 15:51:35 +01:00
Dogukan Karatas 59dd7ee9fc updates input field name 2025-02-17 15:36:47 +01:00
Jedd Morgan a6e7ed86d9 please work (#122)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-02-14 14:15:25 +00:00
Jedd Morgan 573e70df02 Single line command (#121)
* Add quotes

* cmd uses carets

* I hate windows
2025-02-14 14:12:44 +00:00
Jedd Morgan 52f406f3ac Fix CI multi-line command (#120)
* Add quotes

* cmd uses carets
2025-02-14 14:07:40 +00:00
Jedd Morgan fbe34d43b1 Add quotes (#119) 2025-02-14 13:59:26 +00:00
Dogukan Karatas 5ad8139a4c Merge pull request #118 from specklesystems/jedd/cxpla-125-sign-the-power-bi-installer
Jedd/cxpla 125 sign the power bi installer
2025-02-14 14:53:40 +01:00
Jedd Morgan ba43724443 Self sign connector 2025-02-14 13:51:12 +00:00
Dogukan Karatas 8a34685b00 Merge pull request #117 from specklesystems/dogukankaratas-patch-1
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
Update build_powerbi.yml
2025-02-14 13:51:00 +01:00
Dogukan Karatas e9c2293736 Update build_powerbi.yml 2025-02-14 13:49:11 +01:00
Dogukan Karatas d27adb243f Merge pull request #116 from specklesystems/oguzhan/streaming-data-poc
Feat(visual): streaming data
2025-02-14 13:27:07 +01:00
Dogukan Karatas e55a382d73 updates input field name 2025-02-14 13:24:23 +01:00
oguzhankoral 00af5b5abb compress data to write read 2025-02-14 03:09:03 +03:00
oguzhankoral cba5ccf254 handle ndjson streamed data 2025-02-13 20:29:40 +03:00
oguzhankoral ae3c480f02 bump only viewer 2025-02-13 15:27:25 +03:00
oguzhankoral 0b716eb2cd Merge remote-tracking branch 'origin/dev' into oguzhan/streaming-data-poc 2025-02-13 14:52:23 +03:00
oguzhankoral a88f3323b1 bump speckle packages 2025-02-13 14:47:40 +03:00
Dogukan Karatas 7b06aef1f8 feature (data connector): server integration (#115)
* adds version parsing in data connector

* integrate with server

* adds data cleaning
2025-02-13 13:50:55 +03:00
oguzhankoral a45a67ed38 Cleanup 2025-02-13 13:23:14 +03:00
oguzhankoral efd07d4ffb streaming data + progress 2025-02-12 22:23:27 +03:00
Jedd Morgan 33116083e8 bump-version
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-02-07 11:03:47 +00:00
Dogukan Karatas 12f548bd76 Merge pull request #113 from specklesystems/oguzhan/post-with-id-param
Feat: post with id param
2025-02-07 10:42:44 +01:00
oguzhankoral 8b9c4b1cf2 pass root id as param to send 2025-02-05 13:06:09 +00:00
Dogukan Karatas deb34e9262 adds the root id to key (#112) 2025-02-05 10:11:11 +00:00
Oğuzhan Koral 4a4a7d70c0 Rename the event to Receive (#111) 2025-02-04 19:38:20 +00:00
Jedd Morgan 4e1ce9ce71 bump service to 0.4.0 (#110) 2025-02-04 18:26:53 +00:00
Dogukan Karatas c9c1820e4d fix (data connector): adds caching to data connector (#109)
* adds caching to data connector

* endpoint updated
2025-02-04 18:26:33 +00:00
Oğuzhan Koral 62110739fb bump service version to 0.3.0 (#108)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-02-02 21:33:53 +03:00
Oğuzhan Koral d5527c79b1 bump service tag (#107)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-01-31 12:27:54 +03:00
Dogukan Karatas 79ba5cd365 Merge pull request #106 from specklesystems/dogukan/get-user-info
feat: send to local server
2025-01-31 10:26:13 +01:00
Jedd Morgan 17c857d138 Pass service as artifact (#105) 2025-01-31 10:50:16 +03:00
oguzhankoral f0538c94c3 Proper mixpanel track 2025-01-29 22:27:43 +03:00
Dogukan Karatas b1f2c741ad gets user info 2025-01-29 18:57:58 +01:00
Oğuzhan Koral 64dc73411a Feat(service): viewer data from local server (#104)
* Join strings

* Join strings

* POC from local viewer data

* WIP

* more WIP

* Align states

* aligns with new visual

* sends json to server

* Remove unused references

* POC

* local server data added

* updated nav table

* status updated

* added root object id

* status hidden

* Have rootObjectId as field

* Extract writeObjectsToFile

* Working state of interactivity

* Fix load from file vs. from local server

* TODO note

* Enable camera views

* sendStatus added

* cleaner structure

* Align port number with service

* Remove handle file change input

* Remove unused function in viewer

* Update capabilities for port

---------

Co-authored-by: Dogukan Karatas <karatasdogukan@gmail.com>
2025-01-29 18:02:24 +03:00
Oğuzhan Koral 8816430f51 Remove menu buttons from viewer (#102)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-01-16 20:18:33 +03:00
Oğuzhan Koral d5e8cd8a90 Fix: opening a file that has a visual with tooltip (#101)
* WIP

* Remove speckle server urls from webaccess

* Offline support identifier for mixpanel tracks

* Do not select if null

* Remove throttle

* Simplfy logic

* Increase row limit to 150000 for pro users
2025-01-15 14:34:01 +03:00
Dogukan Karatas 3bc82f6b9e updates build yaml file (#100) 2025-01-14 18:51:24 +03:00
Dogukan Karatas dc4f4c19f4 Merge pull request #99 from specklesystems/dogukan/labeling-releases
fix: update version label
2025-01-10 10:25:58 +01:00
Dogukan Karatas 15ebfab50b update gitversion.yml 2025-01-10 10:18:53 +01:00
Dogukan Karatas 10cc50a5b8 creates gitversion.yml (#98) 2025-01-09 18:33:43 +03:00
Dogukan Karatas 049fab417f feat: github action installer (#97)
* rename data column

* create yaml

* update yaml

* gives directory to action

* adds connector to yml

* update yml

* update yml again

* update version

* changes the ref

* adds shell

* fixes typo

* zip the artifacts

* updates version

* adds fetch depth

* updates yml branch

* adds tags
2025-01-09 17:53:15 +03:00
Oğuzhan Koral efe1c7ea0f Capture hits for isolated objects (#96) 2025-01-08 21:32:55 +03:00
Dogukan Karatas 7d7f3b04c5 rename data column (#95) 2025-01-08 18:40:57 +03:00
Dogukan Karatas 8e03d4464e fix: align column and input names (#94)
* rename columns

* updates query
2025-01-08 17:10:27 +03:00
Oğuzhan Koral 41161a8739 Feat(colors): CNX-964 coloring by in power bi visual (#93)
* Group objects by color and with their id

* Have colors back

* Fix color by
2025-01-08 16:23:42 +03:00
Oğuzhan Koral 436d13475d Fix(camera): CNX-969 camera resets when tooltip input is setunset (#92)
* Fix reseting camera on set/unset toolip

* Remove console log

* Remove unnecessary store props methods
2025-01-06 23:43:27 +03:00
Dogukan Karatas 6618e5654b fixes the structure of data connector (#91) 2025-01-06 18:10:49 +03:00
Oğuzhan Koral ca34cd3adc Fix(selection): CNX-963 deselecting does not work (#90)
* Clear selection if no hit

* Remove console log
2025-01-06 17:44:56 +03:00
Oğuzhan Koral 5168516109 Unload objects whenever we load new model (#89) 2025-01-06 14:47:18 +03:00
Dogukan Karatas 2fa69873d1 feat: new data model (#88)
* gets user info

* gets structured data

* gets by url

* publish the function

* cleanup the fields

* creates navigation table

* improves publishing

* builds relation between two table

* expands records dynamically

* folder structure reorder

* adds ordered chunks

* viewer data alternatives

* adds model name to the table name

* adds support for versioned urls

* adds relationship
2024-12-19 12:37:37 +03:00
Oğuzhan Koral 4f15db5d9c Feat: offline support (#87)
* disable redundant steps for POC

* Fixed the getInstances issue. Changed the webpack devsserver port so  it doesn't conflict with the pbviz process

* speckle offline loader tests

* gets user info

* gets structured data

* gets by url

* publish the function

* cleanup the fields

* creates navigation table

* improves publishing

* builds relation between two table

* wip

* some log cleanup and notes

* get sorted data from powerbi

* aling R_ raw data

* WIP

* WIP

* Handle row raw data per object

* introduce pinia

* bump speckle libraries to 2.23.2

* Cleanup on injections

* remove vuex dependency

* extract out button classes

* Revamp store and viewer handler

* Detect reload needed

* Bump the version to 3

* Bump node project to 3

* prettier settings on save and co

* comments

* Enable zoom extends

* Node packages version bumps and selection handler saga

* Cleanup viewer wrapper as MVP

* bump viewer version for offline loader

* correct warning logs

---------

Co-authored-by: AlexandruPopovici <alexandrupopoviciioan@gmail.com>
Co-authored-by: Dogukan Karatas <karatasdogukan@gmail.com>
2024-12-19 12:27:34 +03:00
Kristaps Fabians Geikins e233aec607 chore: add extra instructions for straight up windows (#86) 2024-12-04 15:44:25 +03:00
Kristaps Fabians Geikins 52f4325619 chore: various DX improvements like working source maps (#85)
* init changes

* sourcemaps work

* eff off pbiviz

* more fixes

* moar fixes

* prod build fix

* remove yarn field

* minor scripts change

* moar readme stuff
2024-12-03 22:35:18 +03:00
87 changed files with 8540 additions and 6847 deletions
-3
View File
@@ -1,3 +0,0 @@
for /f "tokens=1 delims=-" %%i in ("%CIRCLE_TAG%") do set "TAG=%%i.%WORKFLOW_NUM%"
for /f "tokens=1 delims=/" %%j in ("%CIRCLE_TAG%") do set "SEMVER=%%j"
tools\InnoSetup\ISCC.exe tools\powerbi.iss /Sbyparam=$p /DINFO_VERSION=%TAG% /DVERSION=%SEMVER% %*
+12 -170
View File
@@ -1,175 +1,17 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/2.0/configuration-reference
version: 2.1
orbs:
win: circleci/windows@5.0
commands:
setup_digicert:
description: Set up Digicert Keylocker certificate for code-signing
steps:
- run:
name: "Digicert Signing Manager Setup"
command: |
cd C:\
curl.exe -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:$env:SM_API_KEY" -o smtools-windows-x64.msi
msiexec.exe /i smtools-windows-x64.msi /quiet /qn | Wait-Process
- run:
name: Setup Digicert ONE Client Cert
command: |
cd C:\
echo $env:SM_CLIENT_CERT_FILE_B64 > certificate.txt
certutil -decode certificate.txt certificate.p12
- run:
name: Sync Certs
command: |
& $env:SSM\smksp_cert_sync.exe
# Define the jobs we want to run for this project
jobs:
build-visual:
docker:
- image: cimg/node:18.20.3
steps:
- checkout
- run: node --version
- run:
name: "npm install"
command: "npm i"
working_directory: src/powerbi-visual
- run:
name: Set version
command: |
npm version ${CIRCLE_TAG:-2.0.0} --allow-same-version
working_directory: src/powerbi-visual
- run:
name: "npm run build"
command: "npm run build"
working_directory: src/powerbi-visual
- run:
name: "npm run pack"
command: "npm run pack"
working_directory: src/powerbi-visual
- store_artifacts:
path: dist/*.pbiviz
- persist_to_workspace:
root: ./
paths:
- src/powerbi-visual/dist/*.pbiviz
build-connector:
executor:
name: win/default
shell: powershell.exe
environment:
SSM: 'C:\Program Files\DigiCert\DigiCert One Signing Manager Tools'
steps:
- checkout
- run:
name: "Set connector internal version"
command: |
$env:VERSION = if([string]::IsNullOrEmpty($env:CIRCLE_TAG)) { "2.0.0" } else { $env:CIRCLE_TAG }
(Get-Content ./Speckle.pq).replace('[Version = "2.0.0"]', '[Version = "'+$($env:VERSION)+'"]') | Set-Content ./Speckle.pq
working_directory: src/powerbi-data-connector
- run:
name: "Build Data Connector"
command: "msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true"
working_directory: src/powerbi-data-connector
- run:
name: Create PQX file
command: .\tools\MakePQX\MakePQX.exe pack -mz src/powerbi-data-connector/bin/Speckle.mez -t src/powerbi-data-connector/bin/Speckle.pqx
- persist_to_workspace:
root: ./
paths:
- src/powerbi-data-connector/bin/Speckle.pqx
build-installer:
executor:
name: win/default
shell: powershell.exe
environment:
SSM: 'C:\Program Files\DigiCert\DigiCert One Signing Manager Tools'
steps:
- checkout
- attach_workspace:
at: ./
- unless: # Build installers unsigned on non-tagged builds
condition: << pipeline.git.tag >>
steps:
- run:
name: Build Installer
shell: cmd.exe #does not work in powershell
environment:
WORKFLOW_NUM: << pipeline.number >>
CIRCLE_TAG: 2.0.0
command: .circleci\build-installer.bat
- when: # Setup certificates and build installers signed for tagged builds
condition: << pipeline.git.tag >>
steps:
- setup_digicert
- run:
name: Build Installer
shell: cmd.exe #does not work in powershell
environment:
WORKFLOW_NUM: << pipeline.number >>
command: .circleci\build-installer.bat /DSIGN_INSTALLER /DCODE_SIGNING_CERT_FINGERPRINT=%SM_CODE_SIGNING_CERT_SHA1_HASH%
- store_artifacts:
path: ./installer
- persist_to_workspace:
root: ./
paths:
- installer/*.exe
deploy-connector-to-feed:
docker:
- image: mcr.microsoft.com/dotnet/sdk:6.0
steps:
- attach_workspace:
at: ./
- run:
name: Install Manager Feed CLI
command: dotnet tool install --global Speckle.Manager.Feed
- run:
name: Upload new version
command: |
TAG=$(if [ "${CIRCLE_TAG}" ]; then echo $CIRCLE_TAG; else echo "2.0.0"; fi;)
SEMVER=$(echo "$TAG" | sed -e 's/\/[a-zA-Z-]*//')
VER=$(echo "$SEMVER" | sed -e 's/-.*//')
VERSION=$(echo $VER.$WORKFLOW_NUM)
/root/.dotnet/tools/Speckle.Manager.Feed deploy -s powerbi -v ${SEMVER} -u https://releases.speckle.dev/installers/powerbi/powerbi-${SEMVER}.exe -o Win -a Any -f ./installer/powerbi-${SEMVER}.exe
environment:
WORKFLOW_NUM: << pipeline.number >>
workflows:
build:
docker:
- image: cimg/base:2023.03
steps:
- run: echo "so long and thanks for all the fish"
# Orchestrate our job run sequence
workflows:
build_and_test:
when:
false
jobs:
- build-connector:
context: digicert-keylocker
- build-visual
- build-installer:
context: digicert-keylocker
requires:
- build-connector
- build-visual
deploy:
jobs:
- build-connector:
filters: &deploy_filter
branches:
ignore: /.*/
tags:
only: /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-\w{1,10})?$/
context: digicert-keylocker
- build-visual:
filters: *deploy_filter
- build-installer:
filters: *deploy_filter
context: digicert-keylocker
requires:
- build-connector
- build-visual
- deploy-connector-to-feed:
filters: *deploy_filter
requires:
- build-installer
context: do-spaces-speckle-releases
- build
+34
View File
@@ -0,0 +1,34 @@
name: Build Connector and Visual
on: pull_request
jobs:
build-connector:
runs-on: windows-latest
outputs:
semver: ${{ steps.set-version.outputs.semver }}
file-version: ${{ steps.set-info-version.outputs.file-version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2
- name: Build Data Connector
working-directory: src/powerbi-data-connector
run: |
msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true
build-visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
working-directory: src/powerbi-visual
- run: npm run build
working-directory: src/powerbi-visual
+145
View File
@@ -0,0 +1,145 @@
name: Depoly Connector and Visual
on:
push:
branches: ["installer-test/**"]
tags: ["v3.*.*"] # Manual delivery on every 3.x tag
jobs:
build-connector:
runs-on: windows-latest
outputs:
semver: ${{ steps.set-version.outputs.semver }}
file-version: ${{ steps.set-info-version.outputs.file-version }}
env:
CertFile: "./speckle.pfx"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3.0.0
with:
versionSpec: 6.0.5 # github actions doesnt like 6.1.0 onwards https://github.com/GitTools/actions/blob/main/docs/versions.md
- name: Determine Version
id: gitversion
uses: gittools/actions/gitversion/execute@v3.0.0
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2
- name: Build Data Connector
working-directory: src/powerbi-data-connector
run: |
msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true
- name: Setup Self-sign certificate
run: |
echo "${{ secrets.SELF_CERT_FILE_B64 }}" > "certificate.txt"
certutil -decode certificate.txt ${{ env.CertFile }}
- name: Create PQX file
run: |
.\tools\MakePQX\MakePQX.exe pack --mez src/powerbi-data-connector/bin/Speckle.mez --target src/powerbi-data-connector/bin/Speckle.pqx --certificate ${{env.CertFile}} --password ${{secrets.SELF_CERT_PASSWORD}}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: powerbi-connector
path: src/powerbi-data-connector/bin/Speckle.pqx
if-no-files-found: error
retention-days: 1
- id: set-version
name: Set version to output
run: echo "semver=${{steps.gitversion.outputs.semVer}}" >> "$GITHUB_OUTPUT"
shell: bash
- id: set-info-version
name: Set version to output
run: echo "file-version=${{steps.gitversion.outputs.AssemblySemVer}}" >> "$GITHUB_OUTPUT"
shell: bash
build-visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3.0.0
with:
versionSpec: 6.0.5 # github actions doesnt like 6.1.0 onwards https://github.com/GitTools/actions/blob/main/docs/versions.md
- name: Determine Version
id: gitversion
uses: gittools/actions/gitversion/execute@v3.0.0
- run: npm ci
working-directory: src/powerbi-visual
- run: npm version ${{steps.gitversion.outputs.semVer}} --allow-same-version
working-directory: src/powerbi-visual
- run: npm run build
working-directory: src/powerbi-visual
- uses: actions/upload-artifact@v4
with:
name: powerbi-visual
path: src/powerbi-visual/dist/*.pbiviz
if-no-files-found: error
retention-days: 1
deploy-installers:
runs-on: ubuntu-latest
needs:
- build-connector
- build-visual
env:
IS_TAG_BUILD: ${{ github.ref_type == 'tag' }}
steps:
- name: download artifacts
uses: actions/download-artifact@v4
with:
name: powerbi-connector
path: artifacts/
- name: download artifacts visual
uses: actions/download-artifact@v4
with:
name: powerbi-visual
path: artifacts/
- name: Zip artifacts
run: |
cd artifacts && zip -r ../powerbi.zip .
- name: upload artifacts
uses: actions/upload-artifact@v4
with:
name: output-${{needs.build-connector.outputs.semver}}
path: powerbi.zip
if-no-files-found: error
retention-days: 1
- name: 🔫 Trigger Build Installer(s)
uses: the-actions-org/workflow-dispatch@v4.0.0
with:
workflow: Build Installers
repo: specklesystems/connector-installers
token: ${{ secrets.CONNECTORS_GH_TOKEN }}
inputs: '{
"run_id": "${{ github.run_id }}",
"semver": "${{ needs.build-connector.outputs.semver }}",
"file_version": "${{ needs.build-connector.outputs.file-version }}",
"repo": "${{ github.repository }}",
"is_public_release": ${{ env.IS_TAG_BUILD }}
}'
ref: main
wait-for-completion: true
wait-for-completion-interval: 10s
wait-for-completion-timeout: 10m
display-workflow-run-url: true
display-workflow-run-url-interval: 10s
- uses: geekyeggo/delete-artifact@v5
with:
name: output-*
+3
View File
@@ -341,3 +341,6 @@ ASALocalRun/
**/webpack.statistics.html
**/Thumbs.db
installer/
localhost.pem
localhost-key.pem
+11
View File
@@ -0,0 +1,11 @@
workflow: GitFlow/v1
next-version: 3.0.0
mode: ManualDeployment
branches:
main:
label: rc
develop:
regex: ^dev$
label: beta
unknown:
increment: None
+6
View File
@@ -0,0 +1,6 @@
{
"name": "speckle-powerbi",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+15 -6
View File
@@ -1,15 +1,15 @@
{
"folders": [
{
"name": "root",
"name": "🏠 root",
"path": "."
},
{
"name": "DataConnector",
"name": "➡️ powerbi-data-connector",
"path": "src/powerbi-data-connector"
},
{
"name": "Visual",
"name": "👀 powerbi-visual",
"path": "src/powerbi-visual"
}
],
@@ -23,12 +23,20 @@
"**/node_modules/**": true,
".tmp": true
},
"files.exclude": {
".tmp": true
"editor.formatOnPaste": true,
"editor.multiCursorModifier": "ctrlCmd",
"editor.snippetSuggestions": "top",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"search.exclude": {
".tmp": true,
"typings": true
"typings": true,
"dist": true,
"wepbpack.statistics.dev.html": true,
"wepbpack.statistics.html": true
},
"json.schemas": [
{
@@ -47,6 +55,7 @@
},
"extensions": {
"recommendations": [
"esbenp.prettier-vscode",
"ms-dotnettools.csharp",
"powerquery.vscode-powerquery-sdk"
]
+112 -125
View File
@@ -1,14 +1,122 @@
[Version = "2.0.0"]
[Version = "3.0.0"]
section Speckle;
AuthAppId = "spklpwerbi";
AuthAppSecret = "spklpwerbi";
// The data source definition, used when connecting to any speckle server
// function to load `pqm` files - this is essential and must be kept
shared Speckle.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared)
catch (e) =>
error
[
Reason = "Speckle.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
];
// here we register the functions to expose them globally
[DataSource.Kind = "Speckle"]
shared Speckle.Parser = Value.ReplaceType(
Speckle.LoadFunction("Parser.pqm"),
type function (url as Uri.Type) as record
);
[DataSource.Kind = "Speckle"]
shared Speckle.Api.Fetch = Value.ReplaceType(
Speckle.LoadFunction("Api.Fetch.pqm"),
type function (url as Uri.Type, optional query as text, optional variables as record) as record
);
[DataSource.Kind = "Speckle"]
shared Speckle.GetUser = Value.ReplaceType(
Speckle.LoadFunction("GetUser.pqm"),
type function (url as Uri.Type) as record
);
[DataSource.Kind = "Speckle"]
shared Speckle.GetModel = Value.ReplaceType(
Speckle.LoadFunction("GetModel.pqm"),
type function (url as Uri.Type) as record
);
[DataSource.Kind = "Speckle"]
shared Speckle.GetStructuredData = Value.ReplaceType(
Speckle.LoadFunction("GetStructuredData.pqm"),
type function (url as Uri.Type) as table
);
[DataSource.Kind = "Speckle"]
shared Speckle.SendToServer = Value.ReplaceType(
Speckle.LoadFunction("SendToServer.pqm"),
type function (url as Uri.Type) as table
);
[DataSource.Kind = "Speckle", Publish="GetByUrl.Publish"]
shared Speckle.GetByUrl = Value.ReplaceType(
Speckle.LoadFunction("GetByUrl.pqm"),
type function (
url as (
Uri.Type meta [
Documentation.FieldCaption = "Speckle Model URL",
Documentation.FieldDescription = "The URL of a model in a Speckle server project. You can copy it directly from your browser.",
Documentation.SampleValues = {"https://app.speckle.systems/projects/7902de1f57/models/7f890a65df"}
]
)
) as table meta [
Documentation.Name = "Speckle - Get Data by URL",
Documentation.DisplayName = "Speckle - Get Data by URL",
Documentation.LongDescription = "Returns structured data from a Speckle model URL.#(lf)
Supports the following URL formats:#(lf)
- Model URL: Gets the latest version of the specified model#(lf)
(e.g., 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID')#(lf)
- Version URL: Gets a specific version from the project#(lf)
(e.g., 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID@VERSION_ID')"
]
);
shared Speckle.Revit.Parameters.ToNameValueRecord = (r as record, optional exclude as list) as record =>
let
defaultExclude = {"id", "speckle_type", "applicationId", "totalChildrenCount"},
fullExclusion = if exclude = null then defaultExclude else List.Union(defaultExclude, exclude),
clean = Record.RemoveFields(r, fullExclusion, MissingField.Ignore),
recTable = Record.ToTable(clean),
cleanTable = Table.RemoveColumns(recTable, "Name"),
expanded = Table.ExpandRecordColumn(
cleanTable, "Value", {"name", "value", "applicationInternalName"}, {"Name", "Value", "UID"}
),
joined = Table.AddColumn(expanded, "Combo", each [Name] & " [" & [UID] & "]"),
renamed = Table.RenameColumns(joined, {{"Name", "x"}, {"Combo", "Name"}}),
result = Record.FromTable(renamed)
in
result;
// here we register the GetByUrl function to power bi ui
GetByUrl.Publish = [
Cateogry = "Other",
ButtonText = {"Connect to Speckle"},
LearnMoreUrl = "https://speckle.guide/user/powerbi/introduction.html",
SourceImage = GetByUrl.Icons,
SourceTypeImage = GetByUrl.Icons
];
GetByUrl.Icons = [
Icon16 = { Extension.Contents("SpeckleLogo16.png"), Extension.Contents("SpeckleLogo20.png"), Extension.Contents("SpeckleLogo24.png"), Extension.Contents("SpeckleLogo32.png") },
Icon32 = { Extension.Contents("SpeckleLogo32.png"), Extension.Contents("SpeckleLogo40.png"), Extension.Contents("SpeckleLogo48.png"), Extension.Contents("SpeckleLogo64.png") }
];
// The data source definition
Speckle = [
// This is used when running the connector on an on-premises data gateway
TestConnection = (path) => {"Speckle.Api.GetUser", path},
// This is the custom authentication strategy for our Connector
TestConnection = (path) => {"Speckle.GetUser", path},
// Authentication strategy
Authentication = [
OAuth = [
Label = "Speckle Account",
@@ -94,124 +202,3 @@ Speckle = [
],
Label = "Speckle"
];
// Gets the object referenced by a specific speckle URL
[DataSource.Kind = "Speckle", Publish = "Get.ByUrl.Publish"]
shared Speckle.GetByUrl.Structured = Value.ReplaceType(
Speckle.LoadFunction("Get.ByUrl.pqm"),
type function (
url as (
Uri.Type meta [
Documentation.FieldCaption = "Gets a Speckle Object preserving it's structure",
Documentation.FieldDescription = "The url of a model in a Speckle server project. You can copy it directly from your browser.",
Documentation.SampleValues = {"https://app.speckle.systems/projects/23401adf/models/1234568"}
]
)
) as record meta [
Documentation.Name = "Speckle - Get Structured Object by URL",
Documentation.LongDescription = "Returns the Speckle object the URL points to, while also preserving it's structure.
Supports all types of model url:#(lf)
- Model: will get the latest version of the specified model (i.e. 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID')#(lf)
- Version: will get a specific version from the project (i.e. 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID@VERSION_ID')
"
]
);
// [DataSource.Kind = "Speckle", Publish = "NavTable.Publish"]
// shared Speckle.GetObjectAsNavTable = Value.ReplaceType(
// NavigationTable.Simple, type function (url as Uri.Type) as table
// );
// Get's a flat list of speckle objects from a URL
[DataSource.Kind = "Speckle", Publish = "GetByUrl.Publish"]
shared Speckle.GetByUrl = Value.ReplaceType(
Speckle.LoadFunction("GetByUrl.pqm"),
type function (
url as (
Uri.Type meta [
Documentation.FieldCaption = "Model URL",
Documentation.FieldDescription = "The url of a model in a Speckle server. You can copy it directly from your browser.",
Documentation.SampleValues = {"https://app.speckle.systems/projects/23401adf/models/1234568"}
]
)
) as table meta [
Documentation.Name = "Speckle - Get Model by URL",
Documentation.LongDescription = "Returns a flat list of all objects contained in a Speckle model/version of a specific a project.
Supports all types of model url:#(lf)
- Model: will get the latest version of the specified model (i.e. 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID')#(lf)
- Version: will get a specific version from the project (i.e. 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID@VERSION_ID')
"
]
);
// Gets the current authenticated user, if any
[DataSource.Kind = "Speckle"]
shared Speckle.Api.GetUser = Value.ReplaceType(
Speckle.LoadFunction("Api.GetUser.pqm"), type function (url as Uri.Type) as record
);
// Generic fetch function to our GraphQL endpoint
[DataSource.Kind = "Speckle"]
shared Speckle.Api.Fetch = Value.ReplaceType(
Speckle.LoadFunction("Api.Fetch.pqm"),
type function (url as Uri.Type, optional query as text, optional variables as record) as record
);
// Parses a stream url and returns a record with the type and values
[DataSource.Kind = "Speckle"]
shared Speckle.ParseUrl = Value.ReplaceType(
Speckle.LoadFunction("ParseStreamUrl.pqm"), type function (url as Uri.Type) as record
);
// [DataSource.Kind = "Speckle"]
// shared Speckle.Api.REST.GetObject = Value.ReplaceType(
// Speckle.LoadFunction("Api.REST.GetObject.pqm"),
// type function (url as Uri.Type, optional streamId as text, optional objectId as text) as list
// );
Get.ByUrl.Publish = GetPublish("GetStream");
NavTable.Publish = GetPublish("GetObjectAsNavTable");
GetByUrl.Publish = GetPublish("GetByUrl");
GetPublish = Speckle.LoadFunction("GetPublish.pqm");
// Navigation table utility function
Table.ToNavigationTable = Speckle.LoadFunction("Table.ToNavigationTable.pqm");
// Function to load `pqm` files
shared Speckle.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Speckle.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
];
shared Speckle.Revit.Parameters.ToNameValueRecord = (r as record, optional exclude as list) as record =>
let
defaultExclude = {"id", "speckle_type", "applicationId", "totalChildrenCount"},
fullExclusion = if exclude = null then defaultExclude else List.Union(defaultExclude, exclude),
clean = Record.RemoveFields(r, fullExclusion, MissingField.Ignore),
recTable = Record.ToTable(clean),
cleanTable = Table.RemoveColumns(recTable, "Name"),
expanded = Table.ExpandRecordColumn(
cleanTable, "Value", {"name", "value", "applicationInternalName"}, {"Name", "Value", "UID"}
),
joined = Table.AddColumn(expanded, "Combo", each [Name] & " [" & [UID] & "]"),
renamed = Table.RenameColumns(joined, {{"Name", "x"}, {"Combo", "Name"}}),
result = Record.FromTable(renamed)
in
result;
shared Speckle.Utils.DynamicColumnExpand = (tbl as table, col as text) as table =>
let
uniqueFields = List.Distinct(List.Combine(List.Transform(Table.Column(tbl, col), Record.FieldNames))),
expanded = Table.ExpandRecordColumn(tbl, col, uniqueFields)
in
expanded;
-1
View File
@@ -12,7 +12,6 @@
</PropertyGroup>
<ItemGroup>
<MezContent Include="Speckle.pq" />
<MezContent Include="utilities\**\*.pqm" />
<MezContent Include="speckle\**\*.pqm" />
<MezContent Include="assets\SpeckleLogo16.png" />
<MezContent Include="assets\SpeckleLogo20.png" />
+4 -2
View File
@@ -1,7 +1,9 @@
// Use this file to write queries to test your data connector
// use this file to write queries to test your data connector
// NOTE! for tests, be make sure you put here a model that in private project to make sure all good.
let
result = Speckle.GetByUrl(
"https://app.speckle.systems/projects/e2988234fb/models/60b2300470@b1f31a351a,60b2300470"
"https://latest.speckle.systems/projects/126cd4b7bb/models/85c44d39c6"
)
in
result
@@ -1,48 +0,0 @@
let
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(server as text, streamId as text, branchName as text, limit as number) as list =>
let
decodedBranchName = Record.Field(
Record.Field(Uri.Parts("http://www.dummy.com?" & Uri.BuildQueryString([A = branchName])), "Query"),
"A"
),
// Hacky way to decode base64 strings: Put them in a url query param and parse the URL
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
query = "query($streamId: String!, $branchName: String!, $limit: Int!) {
stream( id: $streamId ) {
branch (name: $branchName ){
commits (limit: $limit) {
items {
id
referencedObject
sourceApplication
}
}
}
}
}",
res = Fetch(server, query, [streamId = streamId, branchName = decodedBranchName, limit = limit]),
branch = res[stream][branch],
commits = branch[commits][items]
in
if branch = null then
error Text.Format("The branch '#{0}' does not exist in stream '#{1}'", {decodedBranchName, streamId})
else if List.Count(branch[commits][items]) = 0 then
error Text.Format("The branch '#{0}' in stream #{1} has no commits", {decodedBranchName, streamId})
else
commits
@@ -1,47 +0,0 @@
let
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
Traverse = Extension.LoadFunction("Traverse.pqm"),
GetObject = Extension.LoadFunction("Api.GetObject.pqm"),
GetStreamCommit = Extension.LoadFunction("Get.StreamCommit.pqm"),
GetBranchCommits = Extension.LoadFunction("Get.BranchCommits.pqm"),
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
ParseStreamUrl = Extension.LoadFunction("ParseStreamUrl.pqm"),
CleanUpObject = Extension.LoadFunction("CleanUpObject.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(url as text) as record =>
let
// Get server and streamId, and branchName / commitId / objectid from the input url
stream = ParseStreamUrl(url),
id = stream[id],
server = stream[server],
commit =
if (stream[urlType] = "Stream") then
GetBranchCommits(server, id, "main", 1){0}
else if (stream[urlType] = "Branch") then
GetBranchCommits(server, id, stream[branch], 1){0}
else if (stream[urlType] = "Commit") then
GetStreamCommit(server, id, stream[commit])
else
//We deal with object URLs directly
[referencedObject = stream[object]],
object = GetObject(server, id, commit[referencedObject]),
rr = CommitReceived(server, id, commit),
result = Traverse(CleanUpObject(object) meta [server = server, stream = id, commit = commit])
in
if rr then
result
else
result
@@ -1,36 +0,0 @@
let
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(server as text, streamId as text, commitId as text) as record =>
let
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
query = "query($streamId: String!, $commitId: String!) {
stream( id: $streamId ) {
commit (id: $commitId) {
id
sourceApplication
referencedObject
}
}
}",
variables = [streamId = streamId, commitId = commitId],
#"JSON" = Fetch(server, query, variables),
commit = #"JSON"[stream][commit]
in
if commit = null then
error "The commit did not exist on this stream"
else
commit
@@ -1,64 +0,0 @@
(appName as text) =>
let
replaced = Text.Replace(appName, " ", ""), name = Text.Lower(replaced)
in
if Text.Contains(name, "dynamo") then
"dynamo"
else if Text.Contains(name, "revit") then
"revit"
else if Text.Contains(name, "autocad") then
"autocad"
else if Text.Contains(name, "civil") then
"civil"
else if Text.Contains(name, "rhino") then
"rhino"
else if Text.Contains(name, "grasshopper") then
"grasshopper"
else if Text.Contains(name, "unity") then
"unity"
else if Text.Contains(name, "gsa") then
"gsa"
else if Text.Contains(name, "microstation") then
"microstation"
else if Text.Contains(name, "openroads") then
"openroads"
else if Text.Contains(name, "openrail") then
"openrail"
else if Text.Contains(name, "openbuildings") then
"openbuildings"
else if Text.Contains(name, "etabs") then
"etabs"
else if Text.Contains(name, "sap") then
"sap"
else if Text.Contains(name, "csibridge") then
"csibridge"
else if Text.Contains(name, "safe") then
"safe"
else if Text.Contains(name, "teklastructures") then
"teklastructures"
else if Text.Contains(name, "dxf") then
"dxf"
else if Text.Contains(name, "excel") then
"excel"
else if Text.Contains(name, "unreal") then
"unreal"
else if Text.Contains(name, "powerbi") then
"powerbi"
else if Text.Contains(name, "blender") then
"blender"
else if Text.Contains(name, "qgis") then
"qgis"
else if Text.Contains(name, "arcgis") then
"arcgis"
else if Text.Contains(name, "sketchup") then
"sketchup"
else if Text.Contains(name, "archicad") then
"archicad"
else if Text.Contains(name, "topsolid") then
"topsolid"
else if Text.Contains(name, "python") then
"python"
else if Text.Contains(name, "net") then
"net"
else
"other"
+106 -52
View File
@@ -1,58 +1,112 @@
let
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
GetObject = Extension.LoadFunction("Api.GetObject.pqm"),
GetAllObjectChildren = Extension.LoadFunction("Api.GetAllObjectChildren.pqm"),
GetObjectFromCommit = Extension.LoadFunction("GetObjectFromCommit.pqm"),
GetObjectFromBranch = Extension.LoadFunction("GetObjectFromBranch.pqm"),
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
ParseStreamUrl = Extension.LoadFunction("ParseStreamUrl.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
(url as text) as table =>
let
// import required functions
GetStructuredData = Extension.LoadFunction("GetStructuredData.pqm"),
SendToServer = Extension.LoadFunction("SendToServer.pqm"),
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
// the logic for importing functions from other files
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared)
catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(url as text) as table =>
let
// Get server and streamId, and branchName / commitId / objectid from the input url
stream = ParseStreamUrl(url),
id = stream[id],
server = stream[server],
commitObjectsTable =
if (stream[urlType] = "Commit") then
GetObjectFromCommit(server, id, stream[commit])
else if (stream[urlType] = "Object") then
GetAllObjectChildren(server, id, stream[object])
else if (stream[urlType] = "Branch") then
GetObjectFromBranch(server, id, stream[branch])
else
GetObjectFromBranch(server, id, "main"),
addStreamUrl = Table.AddColumn(commitObjectsTable, "Model URL", each server & "/streams/" & id),
addParentObjectId = Table.AddColumn(
addStreamUrl, "Version Object ID", each Value.Metadata(commitObjectsTable)[objectId]
),
addUrlType = Table.AddColumn(addParentObjectId, "URL Type", each stream[urlType]),
addObjectIdCol = Table.AddColumn(addUrlType, "Object ID", each try[data][id] otherwise null),
addSpeckleTypeCol = Table.AddColumn(
addObjectIdCol, "speckle_type", each try[data][speckle_type] otherwise null
),
final = Table.ReorderColumns(
addSpeckleTypeCol, {
"Model URL",
"URL Type",
"Version Object ID",
"Object ID",
"speckle_type",
"data"
}
)
in
final
],
// parse the URL to determine if it's a federated model
parsedUrl = Parser(url),
// function to process a single model and get its data
ProcessSingleModel = (baseUrl, projectId, modelId, versionId) =>
let
// construct a standard URL for the model
singleModelUrl = Text.Combine({
baseUrl,
"/projects/",
projectId,
"/models/",
modelId,
if versionId <> null then Text.Combine({"@", versionId}) else ""
}),
// get model info
modelInfo = GetModel(singleModelUrl),
rootObjectId = modelInfo[rootObjectId],
modelName = modelInfo[modelName],
// get structured data
structuredData = GetStructuredData(singleModelUrl),
// add the model name as context
result = Table.AddColumn(
structuredData,
"Source Model",
each modelName,
type text
)
in
[
Data = result,
RootObjectId = rootObjectId
],
// check if this is a federated model
results = if parsedUrl[isFederated] = true then
// process each model in the federation
let
modelsData = List.Transform(
parsedUrl[federatedModels],
each ProcessSingleModel(
parsedUrl[baseUrl],
parsedUrl[projectId],
[modelId],
[versionId]
)
),
// extract all data tables
allTables = List.Transform(modelsData, each [Data]),
// extract all root object IDs
allRootIds = List.Transform(modelsData, each [RootObjectId]),
// combine all root object IDs into a comma-separated string
combinedRootIds = Text.Combine(allRootIds, ","),
// combine all data tables
combinedData = Table.Combine(allTables),
// replace the "Version Object ID" column with the combined root IDs
finalData = Table.TransformColumns(
combinedData,
{"Version Object ID", each combinedRootIds}
)
in
finalData
else
// use existing functionality for single models
let
// get model name
modelInfo = GetModel(url),
modelName = modelInfo[modelName],
// get structured data
structuredData = GetStructuredData(url),
// rename column based on send status
newColumnName = "Version Object ID",
result = Table.RenameColumns(structuredData, {{"Version Object ID", newColumnName}})
in
result
in
results
@@ -1,55 +0,0 @@
let
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
GetAllObjectChildren = Extension.LoadFunction("Api.GetAllObjectChildren.pqm"),
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(server as text, streamId as text, branchName as text) as table =>
let
decodedBranchName = Record.Field(
Record.Field(Uri.Parts("http://www.dummy.com?" & Uri.BuildQueryString([A = branchName])), "Query"),
"A"
),
// Hacky way to decode base64 strings: Put them in a url query param and parse the URL
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
query = "query($streamId: String!, $branchName: String!) {
stream( id: $streamId ) {
branch (name: $branchName ){
commits (limit: 1) {
items {
id
referencedObject
sourceApplication
}
}
}
}
}",
res = Fetch(server, query, [streamId = streamId, branchName = decodedBranchName]),
branch = res[stream][branch],
commit = branch[commits][items]{0},
objectsTable = GetAllObjectChildren(server, streamId, commit[referencedObject]),
rr = CommitReceived(server, streamId, commit)
in
if branch = null then
error Text.Format("The branch '#{0}' does not exist in stream '#{1}'", {decodedBranchName, streamId})
else if List.Count(branch[commits][items]) = 0 then
error Text.Format("The branch '#{0}' in stream #{1} has no commits", {decodedBranchName, streamId})
else
// Force evaluation of read receipt (ideally it should happen after fetching, but can't find a way)
if rr then
objectsTable
else
objectsTable
@@ -1,43 +0,0 @@
let
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
GetAllObjectChildren = Extension.LoadFunction("Api.GetAllObjectChildren.pqm"),
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(server as text, streamId as text, commitId as text) as table =>
let
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
query = "query($streamId: String!, $commitId: String!) {
stream( id: $streamId ) {
commit (id: $commitId) {
id
sourceApplication
referencedObject
authorId
}
}
}",
variables = [streamId = streamId, commitId = commitId],
#"JSON" = Fetch(server, query, variables),
commit = #"JSON"[stream][commit],
objectsTable = GetAllObjectChildren(server, streamId, commit[referencedObject]),
rr = CommitReceived(server, streamId, commit)
in
if commit = null then
error "The commit did not exist on this stream"
else if rr then
objectsTable
else
objectsTable
@@ -1,33 +0,0 @@
let
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
Speckle.LogEvent = Extension.LoadFunction("LogEvent.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(server, streamId, commit) =>
let
query = "mutation($input: CommitReceivedInput!) {
commitReceive(input: $input)
}",
variables = [
input = [
streamId = streamId,
commitId = commit[id],
sourceApplication = "PowerBI"
]
],
s = Speckle.LogEvent(server, commit)
in
// Read receipts should fail gracefully no matter what
try Speckle.Api.Fetch(s, query, variables)[commitReceive] otherwise false
@@ -30,4 +30,4 @@
if Record.HasFields(#"JSON", {"errors"}) then
error #"JSON"[errors]{0}[message]
else
#"JSON"[data]
#"JSON"[data]
@@ -1,46 +0,0 @@
let
Table.GenerateByPage = Extension.LoadFunction("Table.GenerateByPage.pqm"),
Speckle.Api.GetObjectChildren = Extension.LoadFunction("Api.GetObjectChildren.pqm"),
Speckle.Api.GetObject = Extension.LoadFunction("Api.GetObject.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
// Read all pages of data.
// After every page, we check the "nextCursor" record on the metadata of the previous request.
// Table.GenerateByPage will keep asking for more pages until we return null.
(server as text, streamId as text, objectId as text, optional cursor as text) as table =>
let
parentObject = Speckle.Api.GetObject(server, streamId, objectId),
childrenTable = Table.GenerateByPage(
(previous) =>
let
// if previous is null, then this is our first page of data
nextCursor = if (previous = null) then cursor else Value.Metadata(previous)[Cursor]?,
// if the cursor is null but the prevous page is not, we've reached the end
page =
if (previous <> null and nextCursor = null) then
null
else
Speckle.Api.GetObjectChildren(server, streamId, objectId, 1000, nextCursor)
in
page
),
parentTable = Table.FromRecords({[data = parentObject]}),
resultTable =
if (Table.ColumnCount(childrenTable) = 0) then
parentTable
else
Table.Combine({parentTable, childrenTable})
in
resultTable meta [server = server, streamId = streamId, objectId = objectId]
@@ -1,32 +0,0 @@
let
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(server as text, projectId as text, modelId as text) =>
let
query = "query Project($projectId: String!, $modelId: String!) {
project(id: $projectId) {
model(id: $modelId) {
name
}
}
}",
variables = [
projectId = projectId,
modelId = modelId
]
in
// Read receipts should fail gracefully no matter what
try Speckle.Api.Fetch(server, query, variables)[project][model] otherwise null
@@ -1,28 +0,0 @@
let
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(server as text, streamId as text, objectId as text) =>
let
query = "query($streamId: String!, $objectId: String!) {
stream( id: $streamId ) {
object (id: $objectId) {
data
}
}
}",
#"JSON" = Speckle.Api.Fetch(server, query, [streamId = streamId, objectId = objectId])
in
#"JSON"[stream][object][data]
@@ -1,54 +0,0 @@
let
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
Speckle.CleanUpObjects = Extension.LoadFunction("CleanUpObjects.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(
server as text,
streamId as text,
objectId as text,
optional limit as number,
optional cursor as text,
optional select as list
) =>
let
query = "query($streamId: String!, $objectId: String!, $limit: Int, $cursor: String, $select: [String]) {
stream( id: $streamId ) {
object (id: $objectId) {
children(select: $select, limit: $limit, cursor: $cursor) {
cursor
objects {
data
}
}
}
}
}",
#"JSON" = Speckle.Api.Fetch(
server,
query,
[
streamId = streamId,
objectId = objectId,
limit = limit,
cursor = cursor,
select = select
]
),
children = #"JSON"[stream][object][children],
nextCursor = children[cursor],
clean = Speckle.CleanUpObjects(children[objects])
in
Table.FromRecords(clean) meta [Cursor = nextCursor]
@@ -1,27 +0,0 @@
(url as text) =>
let
userType = type [name = text, email = text, id = text],
query = "query {
activeUser { name email id }
}",
// Imports
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
ParseUrl = Extension.LoadFunction("ParseStreamUrl.pqm"),
urlObject = ParseUrl(url),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
],
user = Speckle.Api.Fetch(urlObject[server], query)[activeUser]
in
// Read receipts should fail gracefully no matter what
Value.ReplaceType(user, userType)
@@ -1,38 +0,0 @@
(server as text, optional streamId as text, optional objectId as text) as table =>
let
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
Source = Web.Contents(
Text.Combine({server, "objects", streamId, objectId}, "/"),
[
Headers = [
#"Method" = "GET",
#"Content-Type" = "application/json",
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
],
ManualStatusHandling = {400}
]
),
json = Json.Document(Source),
clean = List.Select(json, each _[speckle_type] <> "Speckle.Core.Models.DataChunk"),
t = Table.FromColumns({clean}, {"data"}),
addStreamUrl = Table.AddColumn(t, "Stream URL", each server & "/streams/" & streamId),
addObjectIdCol = Table.AddColumn(addStreamUrl, "Object ID", each try _[data][id] otherwise null),
addSpeckleTypeCol = Table.AddColumn(
addObjectIdCol, "speckle_type", each try _[data][speckle_type] otherwise null
),
Speckle.CleanUpObjects = Extension.LoadFunction("CleanUpObjects.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
addSpeckleTypeCol
@@ -0,0 +1,122 @@
// function for getting model information through graphql query
(url as text) as record =>
let
// import the parser function
Parser = Extension.LoadFunction("Parser.pqm"),
// the logic for importing functions from other files
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared)
catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
],
// parse the url and get necessary fields
parsedUrl = Parser(url),
server = parsedUrl[baseUrl],
projectId = parsedUrl[projectId],
modelId = parsedUrl[modelId],
versionId = parsedUrl[versionId],
// get API key if available
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
// graphql query to get model info including root object id
// includes specific version if provided
query = if versionId = null then
"query ($projectId: String!, $modelId: String!) {
project(id: $projectId) {
model(id: $modelId) {
id
name
versions {
items {
id
referencedObject
sourceApplication
}
}
}
}
}"
else
"query ($projectId: String!, $modelId: String!, $versionId: String!) {
project(id: $projectId) {
model(id: $modelId) {
id
name
version(id: $versionId) {
id
referencedObject
sourceApplication
}
}
}
}",
// include versionId in variables if it exists
variables = if versionId = null then
[
projectId = projectId,
modelId = modelId
]
else
[
projectId = projectId,
modelId = modelId,
versionId = versionId
],
// make the api request
Source = Web.Contents(
Text.Combine({server, "graphql"}, "/"),
[
Headers = [
#"Method" = "POST",
#"Content-Type" = "application/json",
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
],
ManualStatusHandling = {400, 401, 403},
Content = Json.FromValue([
query = query,
variables = variables
])
]
),
// parse the response
JsonResponse = Json.Document(Source),
// extract needed information, now handling both version-specific and latest version cases
result = if Record.HasFields(JsonResponse, {"errors"}) then
error JsonResponse[errors]{0}[message]
else if JsonResponse[data]?[project]?[model] = null then
error "Model not found or access denied. Please check your authentication and model ID."
else if versionId = null then
[
modelId = JsonResponse[data][project][model][id],
modelName = JsonResponse[data][project][model][name],
versionId = JsonResponse[data][project][model][versions][items]{0}[id],
rootObjectId = JsonResponse[data][project][model][versions][items]{0}[referencedObject],
sourceApplication = JsonResponse[data][project][model][versions][items]{0}[sourceApplication]
]
else
[
modelId = JsonResponse[data][project][model][id],
modelName = JsonResponse[data][project][model][name],
versionId = JsonResponse[data][project][model][version][id],
rootObjectId = JsonResponse[data][project][model][version][referencedObject],
sourceApplication = JsonResponse[data][project][model][version][sourceApplication]
]
in
result
@@ -0,0 +1,74 @@
// function for getting structured object data
(url as text) as table =>
let
// import the required functions
GetModel = Extension.LoadFunction("GetModel.pqm"),
SendToServer = Extension.LoadFunction("SendToServer.pqm"),
// the logic for importing functions from other files
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared)
catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
],
// get model info and server data
modelInfo = GetModel(url),
rootId = modelInfo[rootObjectId],
// Get the data from SendToServer - this is already a response from the service
JsonResponse = SendToServer(url),
// convert list to table with all columns expanded
TableFromList = Table.FromList(
JsonResponse,
Splitter.SplitByNothing(),
null,
null,
ExtraValues.Error
),
// fields to remove from data record
FieldsToRemove = {"__closure", "totalChildrenCount", "renderMaterialProxies"},
// create the final table with cleaned data records
FinalTable = Table.FromRecords(
List.Transform(
TableFromList[Column1],
each let
record = _,
fieldsToRemoveForThisRecord = List.Select(
FieldsToRemove,
each Record.HasFields(record, {_})
)
in
[
#"Object IDs" = record[id], // Object IDs
#"Speckle Type" = record[speckle_type], // Speckle Type
#"Version Object ID" = rootId,
data = Record.RemoveFields(record, fieldsToRemoveForThisRecord) // Data
]
)
),
// Filtering logic here
// If, model data contains any DataObject -> fetch only data objects
// If there are no data objects in the data -> fetch everything but DataChunks
HasDataObjects = Table.RowCount(
Table.SelectRows(FinalTable, each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject"))
) > 0,
FilteredTable = if HasDataObjects then
Table.SelectRows(FinalTable, each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject"))
else
Table.SelectRows(FinalTable, each Record.FieldOrDefault([data], "speckle_type", "") <> "Speckle.Core.Models.DataChunk")
in
FilteredTable
@@ -0,0 +1,66 @@
// function for getting the user info with graphql query
let
// import the parser function from Parser.pqm file
Parser = Extension.LoadFunction("Parser.pqm"),
// the logic for importing functions from other files
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared)
catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(url as text) as record =>
let
// get base server URL using the imported function
parsedUrl = Parser(url),
server = parsedUrl[baseUrl],
apiKey = try Extension.CurrentCredential()[Key] otherwise "",
query = "query {
activeUser {
email
name
}
serverInfo {
name
company
version
}
}",
Source = Web.Contents(
Text.Combine({server, "graphql"}, "/"),
[
Headers = [
#"Method" = "POST",
#"Content-Type" = "application/json",
#"Authorization" = if apiKey = "" then "" else Text.Format("Bearer #{0}", {apiKey})
],
ManualStatusHandling = {400},
Content = Json.FromValue([query = query])
]
),
JsonResponse = Json.Document(Source)
in
if Record.HasFields(JsonResponse, {"errors"}) then
error JsonResponse[errors]{0}[message]
else
[
UserEmail = try JsonResponse[data][activeUser][email] otherwise "",
UserName = try JsonResponse[data][activeUser][name] otherwise "",
ServerName = JsonResponse[data][serverInfo][name],
ServerCompany = JsonResponse[data][serverInfo][company],
ServerVersion = JsonResponse[data][serverInfo][version],
Token = if apiKey = "" then null else apiKey[access_token]
]
@@ -0,0 +1,58 @@
// function for parsing the url into base url, project id, model id and version id
(url as text) as record =>
let
urlParts = Uri.Parts(url),
baseUrl = Text.Combine({urlParts[Scheme], "://", urlParts[Host]}),
pathSegments = List.Select(Text.Split(urlParts[Path], "/"), each _ <> ""),
// extract project ID if it exists
projectId = if List.Count(pathSegments) >= 2 and pathSegments{0} = "projects"
then pathSegments{1} else null,
// extract model ID and version ID if they exist
rawModelSegment = if List.Count(pathSegments) >= 4 and pathSegments{2} = "models"
then pathSegments{3} else "",
// check if this is a federated model (contains commas)
isFederated = Text.Contains(rawModelSegment, ","),
// if federated, split by comma to get multiple model IDs
modelSegments = if isFederated
then Text.Split(rawModelSegment, ",")
else {rawModelSegment},
// process each model segment (could be modelID or modelID@versionID)
processedModels = List.Transform(
modelSegments,
each [
modelId = if Text.Contains(_, "@")
then Text.Split(_, "@"){0}
else _,
versionId = if Text.Contains(_, "@")
then Text.Split(_, "@"){1}
else null
]
),
// extract model IDs and version IDs into separate lists
modelIds = List.Transform(processedModels, each [modelId]),
versionIds = List.Transform(processedModels, each [versionId]),
// validate URL structure
isValid = projectId <> null and List.Count(modelIds) > 0 and List.First(modelIds) <> ""
in
if not isValid then
error [
Reason = "Invalid URL",
Message = "The URL must be in the format 'https://server/projects/PROJECT_ID/models/MODEL_ID' or 'https://server/projects/PROJECT_ID/models/MODEL_ID@VERSION_ID' or 'https://server/projects/PROJECT_ID/models/MODEL_ID1,MODEL_ID2'"
]
else
[
baseUrl = baseUrl,
projectId = projectId,
modelId = if isFederated then null else processedModels{0}[modelId],
versionId = if isFederated then null else processedModels{0}[versionId],
isFederated = isFederated,
federatedModels = if isFederated then processedModels else null
]
@@ -0,0 +1,63 @@
(url as text) as list =>
let
// Import required functions
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
GetUser = Extension.LoadFunction("GetUser.pqm"),
// the logic for importing functions from other files
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared)
catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
],
// Get model info and parsed URL
modelInfo = GetModel(url),
parsedUrl = Parser(url),
userInfo = GetUser(url),
// Get API key if available
apiKey = userInfo[Token],
// Get user email from credentials
userEmail = userInfo[UserEmail],
// Prepare request data
requestData = Json.FromValue([
Url = url,
Server = parsedUrl[baseUrl],
Email = userEmail,
ProjectId = parsedUrl[projectId],
ObjectId = modelInfo[rootObjectId],
SourceApplication = modelInfo[sourceApplication],
Token = apiKey
]),
// Send request to local server
Response = Web.Contents(
"http://127.0.0.1:29364/download",
[
Headers = [
#"Content-Type" = "application/json",
#"Method" = "POST"
],
Content = requestData,
ManualStatusHandling = {400, 401, 403, 404, 500}
]
),
// Parse response
JsonResponse = Json.Document(Response)
in
JsonResponse
@@ -1,7 +0,0 @@
(object as record) as record =>
let
hiddenFields = {"__closure", "totalChildrenCount"},
// remove closures from records
clean = Record.RemoveFields(object, hiddenFields, MissingField.Ignore)
in
clean
@@ -1,17 +0,0 @@
(objects as list) as list =>
let
// remove closures from records, and remove DataChunk records
removeClosureField = List.Transform(
objects, each [data = Record.RemoveFields(_[data], "__closure", MissingField.Ignore)]
),
removeTotals = List.Transform(
removeClosureField,
each
[
data = try
Record.RemoveFields(_[data], "totalChildrenCount", MissingField.Ignore) otherwise _[data]
]
),
removed = List.Select(removeTotals, each _[data][speckle_type] <> "Speckle.Core.Models.DataChunk")
in
try removed otherwise objects
@@ -1,30 +0,0 @@
let
beta = true,
category = "Other",
icons = [
Icon16 = {
Extension.Contents("SpeckleLogo16.png"),
Extension.Contents("SpeckleLogo20.png"),
Extension.Contents("SpeckleLogo24.png"),
Extension.Contents("SpeckleLogo32.png")
},
Icon32 = {
Extension.Contents("SpeckleLogo32.png"),
Extension.Contents("SpeckleLogo40.png"),
Extension.Contents("SpeckleLogo48.png"),
Extension.Contents("SpeckleLogo64.png")
}
]
in
(key as text) as record =>
[
Beta = beta,
Category = category,
ButtonText = {
Extension.LoadString(Text.Format("#{0}.Title", {key})),
Extension.LoadString(Text.Format("#{0}.Label", {key}))
},
LearnMoreUrl = "https://speckle.guide",
SourceImage = icons,
SourceTypeImage = icons
]
@@ -1,50 +0,0 @@
let
GetApplicationSlug = Extension.LoadFunction("GetApplicationSlug.pqm"),
GetUser = Extension.LoadFunction("Api.GetUser.pqm"),
Hash = Extension.LoadFunction("Hash.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
(server as text, commit as any) =>
let
trackUrl = "https://analytics.speckle.systems/track?ip=1",
user = GetUser(server),
isMultiplayer = user[id] <> commit[authorId],
body = [
event = "Receive",
properties = [
server_id = Hash(server),
token = "acd87c5a50b56df91a795e999812a3a4",
hostApp = "powerbi",
sourceHostApp = GetApplicationSlug(commit[sourceApplication]),
sourceHostAppVersion = commit[sourceApplication],
isMultiplayer = user[id] <> commit[authorId]
]
],
Result = Web.Contents(
trackUrl,
[
Headers = [
#"Method" = "POST",
#"Accept" = "text/plain",
#"Content-Type" = "application/json"
],
Content = Text.ToBinary(Text.Combine({"data=", Text.FromBinary(Json.FromValue(body))}))
]
),
// Hack to force execution
Join = Text.Combine({server, Text.From(Json.Document(Result))}, "_____"),
Disjoin = Text.Split(Join, "_____"){0}
in
Disjoin
@@ -1,81 +0,0 @@
let
GetModel = Extension.LoadFunction("Api.GetModel.pqm"),
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
],
IsFe2Url = (segments as list) as logical => List.Count(segments) = 4 and segments{2} = "models",
GetUrlType = (branchName as nullable text, commitId as nullable text, objectId as nullable text) as text =>
if (commitId <> null) then
"Commit"
else if (objectId <> null) then
"Object"
else if (branchName <> null) then
"Branch"
else
"Stream",
ParseFe1Url = (server as text, segments as list) as record =>
let
streamId = segments{1},
branchName = if (List.Count(segments) = 4 and segments{2} = "branches") then segments{3} else null,
commitId = if (List.Count(segments) = 4 and segments{2} = "commits") then segments{3} else null,
objectId = if (List.Count(segments) = 4 and segments{2} = "objects") then segments{3} else null,
urlType = GetUrlType(branchName, commitId, objectId)
in
[
urlType = urlType,
server = server as text,
id = streamId as nullable text,
branch = branchName as nullable text,
commit = commitId as nullable text,
object = objectId as nullable text
],
ParseFe2Url = (server as text, segments as list) as record =>
let
streamId = segments{1},
modelList = segments{3},
isMultimodel = Text.Contains(modelList, ","),
firstModel = Text.Split(modelList, ","){0},
modelAndVersion = Text.Split(firstModel, "@"),
modelId = modelAndVersion{0},
versionId = if (List.Count(modelAndVersion) > 1) then modelAndVersion{1} else null,
model = if (modelId <> null) then GetModel(server, streamId, modelId) else null,
urlType = GetUrlType(model[name], versionId, null)
in
if isMultimodel then
error
Error.Record(
"NotSupported",
"Multi-model URLs are not supported.",
"Try to select just one single model in the web app and paste that in."
)
else
[
urlType = urlType,
server = server,
id = streamId,
branch = modelId,
commit = versionId,
object = null
]
in
(url as text) as record =>
let
// Get server and streamId, and branchName / commitId / objectid from the input url
server = Text.Combine({Uri.Parts(url)[Scheme], "://", Uri.Parts(url)[Host]}),
segments = Text.Split(Text.AfterDelimiter(Uri.Parts(url)[Path], "/", 0), "/"),
isFe2 = IsFe2Url(segments)
in
if (isFe2) then
ParseFe2Url(server, segments)
else
ParseFe1Url(server, segments)
@@ -1,67 +0,0 @@
let
GetObject = Extension.LoadFunction("Api.GetObject.pqm"),
Diagnostics.Log = Extension.LoadFunction("Diagnostics.pqm")[LogValue],
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
],
//TODO: Not implemented yet
TraverseTable = (item as table) as table => item,
// Will traverse an undetermined value (list, table, record).
TraverseValue = (i as any) as any =>
let
item = Diagnostics.Log("Traverse value", i) meta Value.Metadata(i)
in
if Value.Is(item, type list) then
// Return a transformed list by traversing all items
Diagnostics.Log(
"List travered",
List.Transform(item, (a) => @TraverseValue(Value.ReplaceMetadata(a, Value.Metadata(i))))
)
else if Value.Is(item, type record) then
// Traverse this record individually
TraverseRecord(item)
else if Value.Is(item, type table) then
// Traverse this table
TraverseTable(item)
else
// If none of the above, assume it's just a primitive type and return it as-is.
item,
// Traverses a generic record
TraverseRecord = (object as record) as any =>
let
isSpeckle = Diagnostics.Log("Is Speckle", Record.HasFields(object, {"speckle_type"})),
isReference = Diagnostics.Log("Is Reference", object[speckle_type] = "reference"),
// Get the names of all fields
fields = Record.FieldNames(object),
// Remove all known fields that don't need traversing
cleanFields = List.RemoveItems(fields, {"id", "speckle_type", "applicationId"}),
// Transform the list of field names into a set of transform operations
transformOps = List.Transform(
cleanFields, each {_, (a) => TraverseValue(Value.ReplaceMetadata(a, Value.Metadata(object)))}
),
// Get the object's metadata (server and stream will be saved in here)
info = Value.Metadata(object)
in
// Transform all fields and return the modified object
if (isReference) then
// Swap reference for call to GetObject
() =>
TraverseValue(
Value.ReplaceMetadata(
GetObject(info[server], info[stream], object[referencedId]), Value.Metadata(object)
)
)
else
try Record.TransformFields(object, transformOps, MissingField.Error) otherwise error "oopsies"
in
TraverseValue
@@ -1,2 +0,0 @@
// Use this file to write queries to test your data connector
let result = Speckle.Api.Fetch("https://latest.speckle.systems") in Record.ToTable(result)
@@ -1,7 +0,0 @@
// Use this file to write queries to test your data connector
let
result = Speckle.Api.REST.GetObject(
"https://latest.speckle.systems", "5f284e5c70", "85e5f250fe591ea74d8d5dc1137a9341"
)
in
result
@@ -1,30 +0,0 @@
section UnitTestingUnitTests;
UT = Speckle.LoadFunction("Facts.pqm");
Fact = UT[Fact];
Facts.Summarize = UT[SummarizeFacts];
shared Speckle.UnitTest = [
// Put any common variables here if you only want them to be evaluated once
// Fact(<Name of the Test>, <Expected Value>, <Actual Value>)
// <Expected Value> and <Actual Value> can be a literal or let statement
facts = {
Fact(
"Check that this function returns 'ABC'",
// name of the test
"ABC",
// expected value
UnitTesting.ReturnsABC()
// expression to evaluate (let or single statement)
),
Fact("Check that this function returns '123'", "123", UnitTesting.Returns123()),
Fact("Result should contain 5 rows", 5, Table.RowCount(UnitTesting.ReturnsTableWithFiveRows())),
Fact("Values should be equal (using a let statement)", "Hello World", let a = "Hello World" in a)
},
report = Facts.Summarize(facts)
][report];
shared UnitTesting.ReturnsABC = () => "ABC";
shared UnitTesting.Returns123 = () => "123";
shared UnitTesting.ReturnsTableWithFiveRows = () => Table.Repeat(#table({"a"}, {{1}}), 5);
@@ -1,2 +0,0 @@
// Use this file to write queries to test your data connector
let result = Speckle.Get.ByUrl("https://latest.speckle.systems/streams/3d25474a18") in Record.ToTable(result)
@@ -1,2 +0,0 @@
// Use this file to write queries to test your data connector
let result = Speckle.GetByUrl("https://latest.speckle.systems/streams/5f284e5c70/objects/85e5f250fe591ea74d8d5dc1137a9341") in result
@@ -1,376 +0,0 @@
let
Diagnostics.LogValue = (prefix, value) =>
Diagnostics.Trace(
TraceLevel.Information,
prefix & ": " & (try Diagnostics.ValueToText(value) otherwise "<error getting value>"),
value
),
Diagnostics.LogValue2 = (prefix, value, result, optional delayed) =>
Diagnostics.Trace(TraceLevel.Information, prefix & ": " & Diagnostics.ValueToText(value), result, delayed),
Diagnostics.LogFailure = (text, function) =>
let
result = try function()
in
if result[HasError] then
Diagnostics.LogValue2(text, result[Error], () => error result[Error], true)
else
result[Value],
Diagnostics.WrapFunctionResult = (innerFunction as function, outerFunction as function) as function =>
Function.From(Value.Type(innerFunction), (list) => outerFunction(() => Function.Invoke(innerFunction, list))),
Diagnostics.WrapHandlers = (handlers as record) as record =>
Record.FromList(
List.Transform(
Record.FieldNames(handlers),
(h) =>
Diagnostics.WrapFunctionResult(Record.Field(handlers, h), (fn) => Diagnostics.LogFailure(h, fn))
),
Record.FieldNames(handlers)
),
Diagnostics.ValueToText = (value) =>
let
_canBeIdentifier = (x) =>
let
keywords = {
"and",
"as",
"each",
"else",
"error",
"false",
"if",
"in",
"is",
"let",
"meta",
"not",
"otherwise",
"or",
"section",
"shared",
"then",
"true",
"try",
"type"
},
charAlpha = (c as number) => (c >= 65 and c <= 90) or (c >= 97 and c <= 122) or c = 95,
charDigit = (c as number) => c >= 48 and c <= 57
in
try
charAlpha(Character.ToNumber(Text.At(x, 0)))
and List.MatchesAll(
Text.ToList(x), (c) => let num = Character.ToNumber(c) in charAlpha(num)
or charDigit(num)
)
and not List.MatchesAny(keywords, (li) => li = x) otherwise false,
Serialize.Binary = (x) => "#binary(" & Serialize(Binary.ToList(x)) & ") ",
Serialize.Date = (x) =>
"#date(" & Text.From(Date.Year(x)) & ", " & Text.From(Date.Month(x)) & ", " & Text.From(Date.Day(x))
& ") ",
Serialize.Datetime = (x) =>
"#datetime("
& Text.From(Date.Year(DateTime.Date(x)))
& ", "
& Text.From(Date.Month(DateTime.Date(x)))
& ", "
& Text.From(Date.Day(DateTime.Date(x)))
& ", "
& Text.From(Time.Hour(DateTime.Time(x)))
& ", "
& Text.From(Time.Minute(DateTime.Time(x)))
& ", "
& Text.From(Time.Second(DateTime.Time(x)))
& ") ",
Serialize.Datetimezone = (x) =>
let
dtz = DateTimeZone.ToRecord(x)
in
"#datetimezone("
& Text.From(dtz[Year])
& ", "
& Text.From(dtz[Month])
& ", "
& Text.From(dtz[Day])
& ", "
& Text.From(dtz[Hour])
& ", "
& Text.From(dtz[Minute])
& ", "
& Text.From(dtz[Second])
& ", "
& Text.From(dtz[ZoneHours])
& ", "
& Text.From(dtz[ZoneMinutes])
& ") ",
Serialize.Duration = (x) =>
let
dur = Duration.ToRecord(x)
in
"#duration("
& Text.From(dur[Days])
& ", "
& Text.From(dur[Hours])
& ", "
& Text.From(dur[Minutes])
& ", "
& Text.From(dur[Seconds])
& ") ",
Serialize.Function = (x) =>
_serialize_function_param_type(
Type.FunctionParameters(Value.Type(x)), Type.FunctionRequiredParameters(Value.Type(x))
)
& " as "
& _serialize_function_return_type(Value.Type(x))
& " => (...) ",
Serialize.List = (x) =>
"{"
& List.Accumulate(
x, "", (seed, item) => if seed = "" then Serialize(item) else seed & ", " & Serialize(item)
)
& "} ",
Serialize.Logical = (x) => Text.From(x),
Serialize.Null = (x) => "null",
Serialize.Number = (x) =>
let
Text.From = (i as number) as text =>
if Number.IsNaN(i) then
"#nan"
else if i = Number.PositiveInfinity then
"#infinity"
else if i = Number.NegativeInfinity then
"-#infinity"
else
Text.From(i)
in
Text.From(x),
Serialize.Record = (x) =>
"[ "
& List.Accumulate(
Record.FieldNames(x),
"",
(seed, item) =>
(if seed = "" then Serialize.Identifier(item) else seed & ", " & Serialize.Identifier(
item
))
& " = "
& Serialize(Record.Field(x, item))
)
& " ] ",
Serialize.Table = (x) =>
"#table( type " & _serialize_table_type(Value.Type(x)) & ", " & Serialize(Table.ToRows(x)) & ") ",
Serialize.Text = (x) => """" & _serialize_text_content(x) & """",
_serialize_text_content = (x) =>
let
escapeText = (n as number) as text =>
"#(#)(" & Text.PadStart(Number.ToText(n, "X", "en-US"), 4, "0") & ")"
in
List.Accumulate(
List.Transform(
Text.ToList(x),
(c) =>
let
n = Character.ToNumber(c)
in
if n = 9 then
"#(#)(tab)"
else if n = 10 then
"#(#)(lf)"
else if n = 13 then
"#(#)(cr)"
else if n = 34 then
""""""
else if n = 35 then
"#(#)(#)"
else if n < 32 then
escapeText(n)
else if n < 127 then
Character.FromNumber(n)
else
escapeText(n)
),
"",
(s, i) => s & i
),
Serialize.Identifier = (x) => if _canBeIdentifier(x) then x else "#""" & _serialize_text_content(x) & """",
Serialize.Time = (x) =>
"#time("
& Text.From(Time.Hour(x))
& ", "
& Text.From(Time.Minute(x))
& ", "
& Text.From(Time.Second(x))
& ") ",
Serialize.Type = (x) => "type " & _serialize_typename(x),
_serialize_typename = (x, optional funtype as logical) =>
/* Optional parameter: Is this being used as part of a function signature? */ let
isFunctionType = (x as type) =>
try if Type.FunctionReturn(x) is type then true else false otherwise false,
isTableType = (x as type) =>
try if Type.TableSchema(x) is table then true else false otherwise false,
isRecordType = (x as type) =>
try if Type.ClosedRecord(x) is type then true else false otherwise false,
isListType = (x as type) => try if Type.ListItem(x) is type then true else false otherwise false
in
if funtype = null and isTableType(x) then
_serialize_table_type(x)
else if funtype = null and isListType(x) then
"{ " & @_serialize_typename(Type.ListItem(x)) & " }"
else if funtype = null and isFunctionType(x) then
"function " & _serialize_function_type(x)
else if funtype = null and isRecordType(x) then
_serialize_record_type(x)
else if x = type any then
"any"
else
let
base = Type.NonNullable(x)
in
(if Type.IsNullable(x) then "nullable " else "")
& (
if base = type anynonnull then
"anynonnull"
else if base = type binary then
"binary"
else if base = type date then
"date"
else if base = type datetime then
"datetime"
else if base = type datetimezone then
"datetimezone"
else if base = type duration then
"duration"
else if base = type logical then
"logical"
else if base = type none then
"none"
else if base = type null then
"null"
else if base = type number then
"number"
else if base = type text then
"text"
else if base = type time then
"time"
else if base = type type then
"type"
else /* Abstract types: */ if base = type function then
"function"
else if base = type table then
"table"
else if base = type record then
"record"
else if base = type list then
"list"
else
"any /*Actually unknown type*/"
),
_serialize_table_type = (x) =>
let
schema = Type.TableSchema(x)
in
"table "
& (
if Table.IsEmpty(schema) then
""
else
"["
& List.Accumulate(
List.Transform(
Table.ToRecords(Table.Sort(schema, "Position")),
each Serialize.Identifier(_[Name]) & " = " & _[Kind]
),
"",
(seed, item) => (if seed = "" then item else seed & ", " & item)
)
& "] "
),
_serialize_record_type = (x) =>
let
flds = Type.RecordFields(x)
in
if Record.FieldCount(flds) = 0 then
"record"
else
"["
& List.Accumulate(
Record.FieldNames(flds),
"",
(seed, item) =>
seed
& (if seed <> "" then ", " else "")
& (
Serialize.Identifier(item)
& "="
& _serialize_typename(Record.Field(flds, item)[Type])
)
)
& (if Type.IsOpenRecord(x) then ",..." else "")
& "]",
_serialize_function_type = (x) =>
_serialize_function_param_type(Type.FunctionParameters(x), Type.FunctionRequiredParameters(x))
& " as "
& _serialize_function_return_type(x),
_serialize_function_param_type = (t, n) =>
let
funsig = Table.ToRecords(
Table.TransformColumns(
Table.AddIndexColumn(Record.ToTable(t), "isOptional", 1), {"isOptional", (x) => x > n}
)
)
in
"("
& List.Accumulate(
funsig,
"",
(seed, item) =>
(if seed = "" then "" else seed & ", ")
& (if item[isOptional] then "optional " else "")
& Serialize.Identifier(item[Name])
& " as "
& _serialize_typename(item[Value], true)
)
& ")",
_serialize_function_return_type = (x) => _serialize_typename(Type.FunctionReturn(x), true),
Serialize = (x) as text =>
if x is binary then
try Serialize.Binary(x) otherwise "null /*serialize failed*/"
else if x is date then
try Serialize.Date(x) otherwise "null /*serialize failed*/"
else if x is datetime then
try Serialize.Datetime(x) otherwise "null /*serialize failed*/"
else if x is datetimezone then
try Serialize.Datetimezone(x) otherwise "null /*serialize failed*/"
else if x is duration then
try Serialize.Duration(x) otherwise "null /*serialize failed*/"
else if x is function then
try Serialize.Function(x) otherwise "null /*serialize failed*/"
else if x is list then
try Serialize.List(x) otherwise "null /*serialize failed*/"
else if x is logical then
try Serialize.Logical(x) otherwise "null /*serialize failed*/"
else if x is null then
try Serialize.Null(x) otherwise "null /*serialize failed*/"
else if x is number then
try Serialize.Number(x) otherwise "null /*serialize failed*/"
else if x is record then
try Serialize.Record(x) otherwise "null /*serialize failed*/"
else if x is table then
try Serialize.Table(x) otherwise "null /*serialize failed*/"
else if x is text then
try Serialize.Text(x) otherwise "null /*serialize failed*/"
else if x is time then
try Serialize.Time(x) otherwise "null /*serialize failed*/"
else if x is type then
try Serialize.Type(x) otherwise "null /*serialize failed*/"
else
"[#_unable_to_serialize_#]"
in
try Serialize(value) otherwise "<serialization failed>"
in
[
LogValue = Diagnostics.LogValue,
LogValue2 = Diagnostics.LogValue2,
LogFailure = Diagnostics.LogFailure,
WrapFunctionResult = Diagnostics.WrapFunctionResult,
WrapHandlers = Diagnostics.WrapHandlers,
ValueToText = Diagnostics.ValueToText
]
@@ -1,17 +0,0 @@
// This is here as reference for copy/pasting wherever there is need for importing pqm files.
let
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared) catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
]
in
Extension.LoadFunction
@@ -1,231 +0,0 @@
let
/// COMMON UNIT TESTING CODE
Fact = (_subject as text, _expected, _actual) as record =>
[
expected = try _expected,
safeExpected = if expected[HasError] then "Expected : " & @ValueToText(expected[Error]) else expected[
Value
],
actual = try _actual,
safeActual = if actual[HasError] then "Actual : " & @ValueToText(actual[Error]) else actual[Value],
attempt = try safeExpected = safeActual,
result = if attempt[HasError] or not attempt[Value] then "Failure" else "Success",
resultOp = if result = "Success" then " = " else " <> ",
addendumEvalAttempt = if attempt[HasError] then @ValueToText(attempt[Error]) else "",
addendumEvalExpected = try @ValueToText(safeExpected) otherwise "...",
addendumEvalActual = try @ValueToText(safeActual) otherwise "...",
fact = [
Result = result & " " & addendumEvalAttempt,
Notes = _subject,
Details = " (" & addendumEvalExpected & resultOp & addendumEvalActual & ")"
]
][fact],
Facts = (_subject as text, _predicates as list) => List.Transform(_predicates, each Fact(_subject, _{0}, _{1})),
Facts.Summarize = (_facts as list) as table =>
[
Fact.CountSuccesses = (count, i) =>
[
result = try i[Result],
sum = if result[HasError] or not Text.StartsWith(result[Value], "Success") then count else count + 1
][sum],
passed = List.Accumulate(_facts, 0, Fact.CountSuccesses),
total = List.Count(_facts),
format = if passed = total then "All #{0} Passed !!!" else "#{0} Passed - #{1} Failed",
result = if passed = total then "Success" else "Failed",
rate = Number.IntegerDivide(100 * passed, total),
header = [
Result = result,
Notes = Text.Format(format, {passed, total - passed}),
Details = Text.Format("#{0}% success rate", {rate})
],
report = Table.FromRecords(List.Combine({{header}, _facts}))
][report],
ValueToText = (value, optional depth) =>
let
List.TransformAndCombine = (list, transform, separator) =>
Text.Combine(List.Transform(list, transform), separator),
Serialize.Binary = (x) => "#binary(" & Serialize(Binary.ToList(x)) & ") ",
Serialize.Function = (x) =>
_serialize_function_param_type(
Type.FunctionParameters(Value.Type(x)), Type.FunctionRequiredParameters(Value.Type(x))
)
& " as "
& _serialize_function_return_type(Value.Type(x))
& " => (...) ",
Serialize.List = (x) => "{" & List.TransformAndCombine(x, Serialize, ", ") & "} ",
Serialize.Record = (x) =>
"[ "
& List.TransformAndCombine(
Record.FieldNames(x),
(item) => Serialize.Identifier(item) & " = " & Serialize(Record.Field(x, item)),
", "
)
& " ] ",
Serialize.Table = (x) =>
"#table( type " & _serialize_table_type(Value.Type(x)) & ", " & Serialize(Table.ToRows(x)) & ") ",
Serialize.Identifier = Expression.Identifier,
Serialize.Type = (x) => "type " & _serialize_typename(x),
_serialize_typename = (x, optional funtype as logical) =>
/* Optional parameter: Is this being used as part of a function signature? */ let
isFunctionType = (x as type) =>
try if Type.FunctionReturn(x) is type then true else false otherwise false,
isTableType = (x as type) =>
try if Type.TableSchema(x) is table then true else false otherwise false,
isRecordType = (x as type) =>
try if Type.ClosedRecord(x) is type then true else false otherwise false,
isListType = (x as type) => try if Type.ListItem(x) is type then true else false otherwise false
in
if funtype = null and isTableType(x) then
_serialize_table_type(x)
else if funtype = null and isListType(x) then
"{ " & @_serialize_typename(Type.ListItem(x)) & " }"
else if funtype = null and isFunctionType(x) then
"function " & _serialize_function_type(x)
else if funtype = null and isRecordType(x) then
_serialize_record_type(x)
else if x = type any then
"any"
else
let
base = Type.NonNullable(x)
in
(if Type.IsNullable(x) then "nullable " else "")
& (
if base = type anynonnull then
"anynonnull"
else if base = type binary then
"binary"
else if base = type date then
"date"
else if base = type datetime then
"datetime"
else if base = type datetimezone then
"datetimezone"
else if base = type duration then
"duration"
else if base = type logical then
"logical"
else if base = type none then
"none"
else if base = type null then
"null"
else if base = type number then
"number"
else if base = type text then
"text"
else if base = type time then
"time"
else if base = type type then
"type"
else /* Abstract types: */ if base = type function then
"function"
else if base = type table then
"table"
else if base = type record then
"record"
else if base = type list then
"list"
else
"any /*Actually unknown type*/"
),
_serialize_table_type = (x) =>
let
schema = Type.TableSchema(x)
in
"table "
& (
if Table.IsEmpty(schema) then
""
else
"["
& List.TransformAndCombine(
Table.ToRecords(Table.Sort(schema, "Position")),
each Serialize.Identifier(_[Name]) & " = " & _[Kind],
", "
)
& "] "
),
_serialize_record_type = (x) =>
let
flds = Type.RecordFields(x)
in
if Record.FieldCount(flds) = 0 then
"record"
else
"["
& List.TransformAndCombine(
Record.FieldNames(flds),
(item) =>
Serialize.Identifier(item)
& "="
& _serialize_typename(Record.Field(flds, item)[Type]),
", "
)
& (if Type.IsOpenRecord(x) then ", ..." else "")
& "]",
_serialize_function_type = (x) =>
_serialize_function_param_type(Type.FunctionParameters(x), Type.FunctionRequiredParameters(x))
& " as "
& _serialize_function_return_type(x),
_serialize_function_param_type = (t, n) =>
let
funsig = Table.ToRecords(
Table.TransformColumns(
Table.AddIndexColumn(Record.ToTable(t), "isOptional", 1), {"isOptional", (x) => x > n}
)
)
in
"("
& List.TransformAndCombine(
funsig,
(item) =>
(if item[isOptional] then "optional " else "")
& Serialize.Identifier(item[Name])
& " as "
& _serialize_typename(item[Value], true),
", "
)
& ")",
_serialize_function_return_type = (x) => _serialize_typename(Type.FunctionReturn(x), true),
Serialize = (x) as text =>
if x is binary then
try Serialize.Binary(x) otherwise "null /*serialize failed*/"
else if x is date then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is datetime then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is datetimezone then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is duration then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is function then
try Serialize.Function(x) otherwise "null /*serialize failed*/"
else if x is list then
try Serialize.List(x) otherwise "null /*serialize failed*/"
else if x is logical then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is null then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is number then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is record then
try Serialize.Record(x) otherwise "null /*serialize failed*/"
else if x is table then
try Serialize.Table(x) otherwise "null /*serialize failed*/"
else if x is text then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is time then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is type then
try Serialize.Type(x) otherwise "null /*serialize failed*/"
else
"[#_unable_to_serialize_#]"
in
try Serialize(value) otherwise "<serialization failed>"
in
[
Fact = Fact,
Facts = Facts,
SummarizeFacts = Facts.Summarize,
ValueToText = ValueToText
]
@@ -1,12 +0,0 @@
(Value as text) =>
let
Solution = Binary.ToText(
Binary.FromList(
Binary.ToList(Binary.Compress(Text.ToBinary(Value, BinaryEncoding.Base64), Compression.GZip))
)
)
in
if Value = null then
null
else
Solution
@@ -1,23 +0,0 @@
(getNextPage as function) as table =>
let
listOfPages = List.Generate(
() => getNextPage(null),
// get the first page of data
(lastPage) => lastPage <> null,
// stop when the function returns null
(lastPage) => getNextPage(lastPage)
// pass the previous page to the next function call
),
// concatenate the pages together
tableOfPages = Table.FromList(listOfPages, Splitter.SplitByNothing(), {"Column1"}),
firstRow = tableOfPages{0} ?
in
// if we didn't get back any pages of data, return an empty table
// otherwise set the table type based on the columns of the first page
if (firstRow = null) then
Table.FromRows({})
else
Value.ReplaceType(
Table.ExpandTableColumn(tableOfPages, "Column1", Table.ColumnNames(firstRow[Column1])),
Value.Type(firstRow[Column1])
)
@@ -1,21 +0,0 @@
(
table as table,
keyColumns as list,
nameColumn as text,
dataColumn as text,
itemKindColumn as text,
itemNameColumn as text,
isLeafColumn as text
) as table =>
let
tableType = Value.Type(table),
newTableType = Type.AddTableKey(tableType, keyColumns, true) meta [
NavigationTable.NameColumn = nameColumn,
NavigationTable.DataColumn = dataColumn,
NavigationTable.ItemKindColumn = itemKindColumn,
Preview.DelayColumn = itemNameColumn,
NavigationTable.IsLeafColumn = isLeafColumn
],
navigationTable = Value.ReplaceType(table, newTableType)
in
navigationTable
@@ -1,14 +0,0 @@
(producer as function, interval as function, optional count as number) as any =>
let
list = List.Generate(
() => {0, null},
(state) => state{0} <> null and (count = null or state{0} < count),
(state) =>
if state{1} <> null then
{null, state{1}}
else
{1 + state{0}, Function.InvokeAfter(() => producer(state{0}), interval(state{0}))},
(state) => state{1}
)
in
List.Last(list)
+2 -4
View File
@@ -1,5 +1,5 @@
{
"editor.tabSize": 4,
"editor.tabSize": 2,
"editor.insertSpaces": true,
"files.eol": "\n",
"files.watcherExclude": {
@@ -7,12 +7,10 @@
"**/node_modules/**": true,
".tmp": true
},
"files.exclude": {
".tmp": true
},
"files.associations": {
"*.resjson": "json"
},
"editor.formatOnSave": true,
"search.exclude": {
".tmp": true,
"typings": true
+23
View File
@@ -76,6 +76,29 @@ You'll need to properly set up the certificate in order to be able to use the ho
> Hot Reload will only work on PowerBI Web (**not** on Desktop).
### Local dev guide (for powerbi-visual)
1. Cd into `./src/powerbi-visual`
1. Run `npm install`
1. To ensure proper SSL cert usage
1. Ensure [mkcert](https://github.com/FiloSottile/mkcert) is installed
1. Run `npm run generate-certs`
1. If you're on Windows or WSL2, you'll need to copy over the root CA to the Windows side and install it there as a trusted root CA.
1. WSL2: Typically its in `~/.local/share/mkcert/rootCA.pem` on WSL2. From bash, `cd` to that folder and then do `explorer.exe .` to open it in Windows Explorer and then copy the pem file to someplace better accessible.
1. Windows: Typically its in `%LOCALAPPDATA%\mkcert\`.
1. Open `crtmgr` and install it into **Trusted Root Certification Authorities**. "Certificates - Current User" > "Trusted Root Certification Authorities" > "Certificates" > Right Click "All Tasks" > "Import" > "Local Machine" > "Place all certificates in the following store" > "Trusted Root Certification Authorities". You may have to set the cert filter to "All Files" to see the `.pem` file.
1. After the cert is installed you may have to restart your browser & dev server
1. Run `npm run dev`
1. PowerBI -> Home > New Report > Paste Or manually enter date > Auto-create > Create
1. In the report, click on 'Edit' to open edit mode, and add a "Developer Visual" visual
#### Source map issues
Make sure you're running the dev build (`npm run dev`) and in your browser's dev tools trigger "Clear source maps cache" and "Enable JavaScript source maps". When everything's working, you should be able to click on the "App mounted" console message's file reference link which will take you to the source-mapped source code in dev tools.
Its still a bit janky in that it maye show multiple files with the same name in the file tree,
but one of those is gonna be the real fully source mapped one.
### Contributing
Please make sure you read the [contribution guidelines](.github/CONTRIBUTING.md) for an overview of the best practices we try to follow.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+56 -57
View File
@@ -1,19 +1,14 @@
{
"dataRoles": [
{
"displayName": "Model URL",
"kind": "Grouping",
"name": "stream"
},
{
"displayName": "Version Object ID",
"kind": "Grouping",
"name": "parentObject"
"name": "rootObjectId"
},
{
"displayName": "Object ID",
"displayName": "Object IDs",
"kind": "Grouping",
"name": "object"
"name": "objectIds"
},
{
"displayName": "Color By",
@@ -23,7 +18,7 @@
{
"displayName": "Tooltip Data",
"kind": "Measure",
"name": "objectData"
"name": "tooltipData"
}
],
"dataViewMappings": [
@@ -32,18 +27,13 @@
"rows": {
"dataReductionAlgorithm": {
"top": {
"count": 30000
"count": 150000
}
},
"select": [
{
"bind": {
"to": "stream"
}
},
{
"bind": {
"to": "parentObject"
"to": "rootObjectId"
}
},
{
@@ -53,7 +43,7 @@
},
{
"for": {
"in": "object"
"in": "objectIds"
}
}
]
@@ -62,46 +52,42 @@
"select": [
{
"bind": {
"to": "objectData"
"to": "tooltipData"
}
}
]
}
}
},
"conditions": [
{
"objectIds": { "max": 1 },
"rootObjectId": { "max": 1 }
}
]
}
],
"objects": {
"storedData": {
"properties": {
"speckleObjects": {
"type": { "text": true }
},
"receiveInfo": {
"type": { "text": true }
}
}
},
"viewMode": {
"properties": {
"defaultViewMode": {
"type": { "text": true }
}
}
},
"camera": {
"properties": {
"defaultView": {
"type": {
"enumeration": [
{
"displayName": "Perspective",
"value": "perspective"
},
{
"displayName": "Top",
"value": "top"
},
{
"displayName": "Front",
"value": "front"
},
{
"displayName": "Left",
"value": "left"
},
{
"displayName": "Back",
"value": "back"
},
{
"displayName": "Right",
"value": "right"
}
]
}
"type": { "text": true }
},
"allowCameraUnder": {
"type": {
@@ -129,6 +115,28 @@
}
}
},
"cameraPosition": {
"properties": {
"positionX": {
"type": { "text": true }
},
"positionY": {
"type": { "text": true }
},
"positionZ": {
"type": { "text": true }
},
"targetX": {
"type": { "text": true }
},
"targetY": {
"type": { "text": true }
},
"targetZ": {
"type": { "text": true }
}
}
},
"color": {
"properties": {
"enabled": {
@@ -205,16 +213,7 @@
{
"essential": true,
"name": "WebAccess",
"parameters": [
"https://app.speckle.systems",
"https://speckle.xyz",
"https://*.speckle.xyz",
"https://latest.speckle.systems",
"https://latest.speckle.dev",
"https://*.speckle.dev",
"https://analytics.speckle.systems",
"*"
]
"parameters": ["https://analytics.speckle.systems", "http://localhost:29364", "*"]
},
{
"essential": false,
+5562 -3708
View File
File diff suppressed because it is too large Load Diff
+32 -26
View File
@@ -7,32 +7,35 @@
},
"license": "MIT",
"scripts": {
"pbiviz": "pbiviz",
"pack": "webpack --config webpack.config.ts",
"build": "webpack --config webpack.config.dev.ts",
"serve": "webpack-dev-server --config webpack.config.dev.ts"
"generate-certs": "mkcert localhost",
"build": "webpack --config webpack.config.ts",
"build:dev": "webpack --config webpack.config.dev.ts",
"dev": "webpack-dev-server --config webpack.config.dev.ts"
},
"dependencies": {
"@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.21.0",
"@speckle/objectloader": "^2.23.8",
"@speckle/tailwind-theme": "2.23.2",
"@speckle/ui-components": "2.23.2",
"@speckle/viewer": "2.23.23",
"color-interpolate": "^1.0.5",
"core-js": "^3.30.2",
"lodash": "^4.17.21",
"nanoevents": "^9.1.0",
"pako": "^2.1.0",
"pinia": "^2.3.0",
"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"
"powerbi-visuals-api": "^5.11.0",
"powerbi-visuals-utils-colorutils": "^6.0.5",
"powerbi-visuals-utils-dataviewutils": "^6.1.0",
"powerbi-visuals-utils-formattingmodel": "^6.0.4",
"powerbi-visuals-utils-interactivityutils": "^6.0.4",
"powerbi-visuals-utils-tooltiputils": "^6.0.4",
"regenerator-runtime": "^0.13.11"
},
"devDependencies": {
"@babel/core": "^7.21.8",
@@ -42,7 +45,7 @@
"@types/lodash": "^4.14.194",
"@types/node": "^20.1.7",
"@types/regenerator-runtime": "^0.13.1",
"@types/three": "^0.152.0",
"@types/three": "^0.140.0",
"@types/webpack": "^5.28.1",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
@@ -58,8 +61,8 @@
"mini-css-extract-plugin": "^2.7.5",
"postcss": "^8.4.23",
"postcss-import": "^15.1.0",
"powerbi-visuals-tools": "^5.4.3",
"powerbi-visuals-webpack-plugin": "^4.0.0",
"powerbi-visuals-tools": "^5.6.0",
"powerbi-visuals-webpack-plugin": "^4.1.0",
"prettier": "^2.8.8",
"style-loader": "^3.3.2",
"tailwindcss": "^3.3.2",
@@ -68,13 +71,16 @@
"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"
"vue": "^3.5.13",
"vue-loader": "^17.4.2",
"vue-template-compiler": "^2.7.16",
"webpack": "^5.97.1",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.2"
},
"version": "2.0.0"
"version": "3.0.0",
"engines": {
"node": "^20.17.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"displayName": "Speckle PowerBI Viewer",
"guid": "specklePowerBiVisual",
"visualClassName": "Visual",
"version": "2.0.0",
"version": "3.0.0.0",
"description": "An interactive 3D viewer for Speckle Data",
"supportUrl": "https://speckle.community",
"gitHubUrl": "https://github.com/specklesystems/speckle-powerbi-visuals"
+13 -15
View File
@@ -1,19 +1,17 @@
<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'" />
<ViewerView v-if="visualStore.isViewerReadyToLoad" />
<HomeView v-else />
</template>
<style scoped></style>
<script setup lang="ts">
import HomeView from './views/HomeView.vue'
import ViewerView from './views/ViewerView.vue'
import { onMounted } from 'vue'
import { useVisualStore } from './store/visualStore'
const visualStore = useVisualStore()
onMounted(() => {
console.log('App mounted')
})
</script>
@@ -1,51 +1,10 @@
<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">
<!-- Canonical Views -->
<Menu as="div" class="relative z-50">
<MenuButton v-slot="{ open }" as="template">
<ButtonToggle flat secondary :active="open">
<VideoCameraIcon class="h-5 w-5" />
@@ -60,7 +19,7 @@ const onClearPalletteClicked = (ev: MouseEvent) => {
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"
class="absolute w-20 left-2 mb-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"
@@ -72,9 +31,9 @@ const onClearPalletteClicked = (ev: MouseEvent) => {
:class="{
'bg-primary text-foreground-on-primary': active,
'text-foreground': !active,
'text-sm py-2 transition': true
'text-sm py-1 transition': true
}"
@click="$emit('view-clicked', view.name.toLowerCase() as CanonicalView)"
@click="handleCameraViewChange(view.name.toLocaleLowerCase() as CanonicalView)"
>
{{ view.name }}
</button>
@@ -86,7 +45,7 @@ const onClearPalletteClicked = (ev: MouseEvent) => {
'text-foreground': !active,
'text-sm py-2 transition': true
}"
@click="$emit('view-clicked', view)"
@click="handleCameraViewChange(view)"
>
{{ view.view.name ?? view.name }}
</button>
@@ -94,6 +53,82 @@ const onClearPalletteClicked = (ev: MouseEvent) => {
</MenuItems>
</Transition>
</Menu>
<!-- Speckle Custom Views -->
<Menu v-if="visualStore.speckleViews.length" as="div" class="relative z-40">
<MenuButton v-slot="{ open }" as="template">
<ButtonToggle flat secondary :active="open">
<ViewsIcon 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-24 left-2 mb-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 visualStore.speckleViews"
:key="view.id"
v-slot="{ active }"
as="template"
>
<button
:class="{
'bg-primary text-foreground-on-primary': active,
'text-foreground': !active,
'text-sm py-2 transition': true
}"
@click="handleCameraViewChange(view)"
>
{{ view.name }}
</button>
</MenuItem>
</MenuItems>
</Transition>
</Menu>
<Menu as="div" class="relative z-30">
<MenuButton v-slot="{ open }" as="template">
<ButtonToggle flat secondary :active="open">
<ViewModesIcon 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-20 left-2 mb-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="(label, mode) in viewModes"
:key="mode"
v-slot="{ active }"
as="template"
>
<button
:class="{
'bg-primary text-foreground-on-primary': active,
'text-foreground': !active,
'text-sm py-1 transition': true
}"
@click="handleCameraViewModeChange(Number(mode))"
>
{{ label }}
</button>
</MenuItem>
</MenuItems>
</Transition>
</Menu>
<!--
<ButtonToggle
flat
secondary
@@ -104,8 +139,74 @@ const onClearPalletteClicked = (ev: MouseEvent) => {
</ButtonToggle>
<ButtonSimple flat secondary @click="onClearPalletteClicked">
<PaintBrushIcon class="h-5 w-5" />
</ButtonSimple>
</ButtonSimple> -->
</ButtonGroup>
</template>
<style scoped></style>
<script setup lang="ts">
import {
VideoCameraIcon,
CubeIcon,
ArrowsPointingOutIcon,
PaintBrushIcon
} from '@heroicons/vue/24/solid'
import ViewModesIcon from 'src/components/icons/ViewModesIcon.vue'
import ViewsIcon from 'src/components/icons/ViewsIcon.vue'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { CanonicalView, SpeckleView, ViewMode } 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 { resetPalette } from 'src/utils/matrixViewUtils'
import { useVisualStore } from '@src/store/visualStore'
const visualStore = useVisualStore()
const emits = defineEmits([
'update:sectionBox',
'view-clicked',
'clear-palette',
'view-mode-clicked'
])
const props = withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
sectionBox: false
})
const canonicalViews = [
{ name: 'Top' },
{ name: 'Front' },
{ name: 'Left' },
{ name: 'Back' },
{ name: 'Right' }
]
const viewModes = {
[ViewMode.DEFAULT]: 'Default',
[ViewMode.DEFAULT_EDGES]: 'Edges',
[ViewMode.SHADED]: 'Shaded',
[ViewMode.PEN]: 'Pen',
[ViewMode.ARCTIC]: 'Arctic',
[ViewMode.COLORS]: 'Colors'
}
const handleCameraViewChange = (view: CanonicalView | SpeckleView) => {
emits('view-clicked', view)
// visualStore.writeCameraViewToFile(view)
}
const handleCameraViewModeChange = (viewMode: ViewMode) => {
emits('view-mode-clicked', viewMode)
visualStore.writeViewModeToFile(viewMode)
}
const onZoomExtentsClicked = (ev: MouseEvent) => {
visualStore.viewerEmit('zoomExtends')
}
const onClearPalletteClicked = (ev: MouseEvent) => {
console.log('Clear pallette clicked')
resetPalette()
emits('clear-palette')
}
</script>
@@ -1,141 +1,79 @@
<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-model:section-box="bboxActive"
:views="views"
class="fixed bottom-6"
@view-clicked="(view) => viewerHandler.setView(view)"
@view-mode-clicked="(viewMode) => viewerHandler.setViewMode(viewMode)"
/>
</div>
</template>
<script async setup lang="ts">
import {
computed,
inject,
onBeforeUnmount,
onMounted,
provide,
Ref,
ref,
watch,
watchEffect
} from 'vue'
import { useStore } from 'vuex'
import { inject, onBeforeUnmount, onMounted, Ref, ref } from 'vue'
import { currentOS, OS } from '../utils/detectOS'
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 { SpeckleView } from '@speckle/viewer'
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'
import { useVisualStore } from '@src/store/visualStore'
import { ViewerHandler } from '@src/plugins/viewer'
import { selectionHandlerKey, tooltipHandlerKey } from '@src/injectionKeys'
const visualStore = useVisualStore()
const { dragged } = useClickDragged()
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)
})
onMounted(async () => {
console.log('Viewer Wrapper mounted')
viewerHandler = new ViewerHandler()
await viewerHandler.init(container.value)
visualStore.setViewerEmitter(viewerHandler.emit)
})
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)
function isMultiSelect(e: MouseEvent) {
if (!e) return false
if (currentOS === OS.MacOS) return e.metaKey || e.shiftKey
else return e.ctrlKey || e.shiftKey
}
async function onCanvasClick(ev: MouseEvent) {
if (dragged.value) return
// eslint-disable-next-line no-debugger
debugger
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)
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)
@@ -154,31 +92,4 @@ async function onCanvasAuxClick(ev: MouseEvent) {
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>
@@ -0,0 +1,39 @@
<template>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 7H6L3 15V17"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M16 7H18L21 15V17"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10 16H14"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 16.5C14 17.4283 14.3687 18.3185 15.0251 18.9749C15.6815 19.6313 16.5717 20 17.5 20C18.4283 20 19.3185 19.6313 19.9749 18.9749C20.6313 18.3185 21 17.4283 21 16.5C21 15.5717 20.6313 14.6815 19.9749 14.0251C19.3185 13.3687 18.4283 13 17.5 13C16.5717 13 15.6815 13.3687 15.0251 14.0251C14.3687 14.6815 14 15.5717 14 16.5Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3 16.5C3 16.9596 3.09053 17.4148 3.26642 17.8394C3.44231 18.264 3.70012 18.6499 4.02513 18.9749C4.35013 19.2999 4.73597 19.5577 5.16061 19.7336C5.58525 19.9095 6.04037 20 6.5 20C6.95963 20 7.41475 19.9095 7.83939 19.7336C8.26403 19.5577 8.64987 19.2999 8.97487 18.9749C9.29988 18.6499 9.55769 18.264 9.73358 17.8394C9.90947 17.4148 10 16.9596 10 16.5C10 16.0404 9.90947 15.5852 9.73358 15.1606C9.55769 14.736 9.29988 14.3501 8.97487 14.0251C8.64987 13.7001 8.26403 13.4423 7.83939 13.2664C7.41475 13.0905 6.95963 13 6.5 13C6.04037 13 5.58525 13.0905 5.16061 13.2664C4.73597 13.4423 4.35013 13.7001 4.02513 14.0251C3.70012 14.3501 3.44231 14.736 3.26642 15.1606C3.09053 15.5852 3 16.0404 3 16.5Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
@@ -0,0 +1,32 @@
<template>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M18.5 8.79167L12 12.5833M18.5 8.79167V15.2917L12 19.0833M18.5 8.79167L12 5L5.5 8.79167M12 12.5833L5.5 8.79167M12 12.5833V19.0833M12 19.0833L5.5 15.2917V8.79167"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.5 15.2917L1.5 17.6251"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18.5 15.2957L22.5 17.629"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 5V1"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
@@ -0,0 +1,41 @@
<template>
<div
:class="[
'relative w-full h-1 bg-blue-500/30 text-xs text-foreground-on-primary overflow-hidden rounded-xl',
showBar ? 'opacity-100' : 'opacity-0'
]"
>
<div class="swoosher relative top-0 bg-blue-500/50"></div>
</div>
</template>
<script setup lang="ts">
import { useMounted } from '@vueuse/core'
import { computed } from 'vue'
const props = defineProps<{ loading: boolean; clientOnly?: boolean }>()
const mounted = useMounted()
const showBar = computed(() => (mounted.value || !props.clientOnly) && props.loading)
</script>
<style scoped>
.swoosher {
width: 100%;
height: 100%;
animation: swoosh 1s infinite linear;
transform-origin: 0% 30%;
}
@keyframes swoosh {
0% {
transform: translateX(0) scaleX(0);
}
40% {
transform: translateX(0) scaleX(0.4);
}
100% {
transform: translateX(100%) scaleX(0.5);
}
}
</style>
@@ -20,18 +20,23 @@ export default class SelectionHandler {
})
}
public set(url: string, data: powerbi.extensibility.ISelectionId) {
this.selectionIdMap.set(url, data)
public set(objectId: string, data: powerbi.extensibility.ISelectionId) {
this.selectionIdMap.set(objectId, data)
}
public async select(url: string, multi = false) {
public async select(objectId: string, multi = false) {
const selectionId = this.selectionIdMap.get(objectId)
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)
await this.selectionManager.select(selectionId, true)
if (this.currentSelection.has(objectId)) {
this.currentSelection.delete(objectId)
} else {
this.currentSelection.add(objectId)
}
} else {
await this.selectionManager.select(this.selectionIdMap.get(url), false)
await this.selectionManager.select(selectionId, false)
this.currentSelection.clear()
this.currentSelection.add(url)
this.currentSelection.add(objectId)
}
}
@@ -1,226 +0,0 @@
import {
CanonicalView,
FilteringState,
LegacyViewer,
IntersectionQuery,
DefaultViewerParams,
Box3,
SpeckleView,
CameraController,
CameraEvent
} 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
}
var camController = this.viewer.getExtension(CameraController)
var angle = settings.camera.allowCameraUnder.value ? Math.PI : Math.PI / 2
camController.options = { maximumPolarAngle: angle }
// 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).on(CameraEvent.LateFrameUpdate, 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).renderingCamera,
worldPosition
)
}
public dispose() {
this.viewer.getExtension(CameraController).dispose()
this.viewer.dispose()
this.viewer = null
}
}
-6
View File
@@ -1,12 +1,6 @@
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()
@@ -0,0 +1,31 @@
import ObjectLoader from '@speckle/objectloader'
import { SpeckleLoader, WorldTree } from '@speckle/viewer'
export class SpeckleObjectsOfflineLoader extends SpeckleLoader {
constructor(targetTree: WorldTree, resourceData: string, resourceId?: string) {
super(targetTree, resourceId || '', undefined, undefined, resourceData)
}
protected initObjectLoader(
_resource: string,
_authToken?: string,
_enableCaching?: boolean,
resourceData?: string | ArrayBuffer
): ObjectLoader {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return ObjectLoader.createFromObjects(resourceData as unknown as [])
}
public async load(): Promise<boolean> {
const rootObject = await this.loader.getRootObject()
if (!rootObject && this._resource) {
console.error('No root id set!')
return false
}
/** If not id is provided, we make one up based on the root object id */
this._resource = this._resource || `/json/${rootObject.id as string}`
return super.load()
}
}
+3
View File
@@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
+262
View File
@@ -0,0 +1,262 @@
import {
DefaultViewerParams,
FilteringState,
IntersectionQuery,
CameraController,
CanonicalView,
ViewModes,
CameraEvent,
SpeckleView,
ViewMode,
Viewer,
HybridCameraController,
SelectionExtension,
FilteringExtension
} from '@speckle/viewer'
import { SpeckleObjectsOfflineLoader } from '@src/laoder/SpeckleObjectsOfflineLoader'
import { useVisualStore } from '@src/store/visualStore'
import { Tracker } from '@src/utils/mixpanel'
import { createNanoEvents, Emitter } from 'nanoevents'
import { ColorPicker } from 'powerbi-visuals-utils-formattingmodel/lib/FormattingSettingsComponents'
import { Vector3 } from 'three'
export interface IViewer {
/**
* Events sent over from the host application.
*/
on: <E extends keyof IViewerEvents>(event: E, callback: IViewerEvents[E]) => void
}
export interface Hit {
guid: string
object?: Record<string, unknown>
point: { x: number; y: number; z: number }
}
export interface IViewerEvents {
ping: (message: string) => void
setSelection: (objectIds: string[]) => void
setViewMode: (viewMode: ViewMode) => void
colorObjectsByGroup: (
colorById: {
objectIds: string[]
slice: ColorPicker
color: string
}[]
) => void
isolateObjects: (objectIds: string[]) => void
unIsolateObjects: () => void
zoomExtends: () => void
loadObjects: (objects: object[]) => void
}
export class ViewerHandler {
public emitter: Emitter
public viewer: Viewer
public cameraControls: CameraController
public filtering: FilteringExtension
public selection: SelectionExtension
private filteringState: FilteringState
constructor() {
this.emitter = createNanoEvents()
this.emit = this.emit.bind(this)
this.emitter.on('ping', this.handlePing)
this.emitter.on('setSelection', this.selectObjects)
this.emitter.on('setViewMode', this.setViewMode)
this.emitter.on('colorObjectsByGroup', this.colorObjectsByGroup)
this.emitter.on('isolateObjects', this.isolateObjects)
this.emitter.on('unIsolateObjects', this.unIsolateObjects)
this.emitter.on('zoomExtends', this.zoomExtends)
this.emitter.on('zoomObjects', this.zoomObjects)
this.emitter.on('loadObjects', this.loadObjects)
}
async init(parent: HTMLElement) {
this.viewer = await createViewer(parent)
this.cameraControls = this.viewer.getExtension(CameraController)
this.filtering = this.viewer.getExtension(FilteringExtension)
this.selection = this.viewer.getExtension(SelectionExtension)
this.cameraControls.on(CameraEvent.Stationary, () => {
console.log('🎬 Storing the camera position into file')
const cameraController = this.viewer.getExtension(CameraController)
const position = cameraController.getPosition()
const target = cameraController.getTarget()
const store = useVisualStore()
store.writeCameraPositionToFile(position, target)
})
}
emit<E extends keyof IViewerEvents>(event: E, ...payload: Parameters<IViewerEvents[E]>): void {
this.emitter.emit(event, ...payload)
}
public zoomObjects = (objectIds: string[], animate = false) => {
/** Second argument here is for animating the camera movement. Default is false */
this.cameraControls.setCameraView(objectIds, animate)
}
public zoomExtends = () => this.cameraControls.setCameraView(undefined, false)
public setView = (view: CanonicalView) => this.cameraControls.setCameraView(view, false)
public setSectionBox = (bboxActive: boolean, objectIds: string[]) => {
// TODO
return
}
public setViewMode(viewMode: ViewMode) {
const viewModes = this.viewer.getExtension(ViewModes)
viewModes.setViewMode(viewMode)
}
public selectObjects = (objectIds: string[]) => {
console.log('🔗 Handling setSelection inside ViewerHandler:', objectIds)
if (objectIds) {
this.selection.selectObjects(objectIds)
}
}
public colorObjectsByGroup = (
colorByIds: {
objectIds: string[]
color: string
}[]
) => {
this.filteringState = this.filtering.setUserObjectColors(colorByIds ?? [])
}
public isolateObjects = (objectIds: string[], ghost: boolean) => {
this.unIsolateObjects()
this.filteringState = this.filtering.isolateObjects(objectIds, 'powerbi', true, ghost)
}
public unIsolateObjects = () => {
if (this.filteringState && this.filteringState.isolatedObjects) {
this.filteringState = this.filtering.unIsolateObjects(
this.filteringState.isolatedObjects,
'powerbi',
true
)
}
}
public intersect = (coords: { x: number; y: number }) => {
const point = this.viewer.Utils.screenToNDC(coords.x, coords.y)
const intQuery: IntersectionQuery = {
operation: 'Pick',
point
}
const res = this.viewer.query(intQuery)
if (!res) {
this.selection.clearSelection()
return
}
return {
hit: this.pickViewableHit(res.objects),
objects: res.objects
}
}
public loadObjects = async (modelObjects: object[][]) => {
await this.viewer.unloadAll()
// const stringifiedObject = JSON.stringify(objects)
const store = useVisualStore()
const speckleViews = []
modelObjects.forEach(async (objects) => {
//@ts-ignore
const loader = new SpeckleObjectsOfflineLoader(this.viewer.getWorldTree(), objects)
const speckleViewsInModel = objects.filter(
//@ts-ignore
(o) => o.speckle_type === 'Objects.BuiltElements.View:Objects.BuiltElements.View3D'
) as SpeckleView[]
speckleViews.concat(speckleViewsInModel)
// Since you are setting another camera position, maybe you want the second argument to false
await this.viewer.loadObject(loader, true)
})
store.setSpeckleViews(speckleViews)
if (store.defaultViewModeInFile) {
this.setViewMode(Number(store.defaultViewModeInFile))
}
Tracker.dataLoaded({ sourceHostApp: store.receiveInfo.sourceApplication })
// camera need to be set after objects loaded
if (store.cameraPosition) {
const position = new Vector3(
store.cameraPosition[0],
store.cameraPosition[1],
store.cameraPosition[2]
)
const target = new Vector3(
store.cameraPosition[3],
store.cameraPosition[4],
store.cameraPosition[5]
)
this.cameraControls.setCameraView({ position, target }, true)
}
}
private handlePing = (message: string) => {
console.log(message)
}
private pickViewableHit(hits: Hit[]): Hit | null {
// The current filtering state
const filteringState = this.filtering.filteringState
// Are there any objects isolated?
const hasIsolatedObjects =
!!filteringState.isolatedObjects && filteringState.isolatedObjects.length !== 0
// Are there any objects hidden?
const hasHiddenObjects =
!!filteringState.hiddenObjects && filteringState.hiddenObjects.length !== 0
// No isolated or hidden objects? Return the first hit
if (hasIsolatedObjects && !hasHiddenObjects) {
return hits.find((h) => filteringState.isolatedObjects.includes(h.guid))
}
for (let k = 0; k < hits.length; k++) {
/** Return the first one that's not hidden or isolated. */
if (
hasIsolatedObjects &&
filteringState.isolatedObjects?.includes(hits[k].guid) &&
hasHiddenObjects &&
filteringState.hiddenObjects?.includes(hits[k].guid)
)
return hits[k]
}
}
public dispose() {
this.viewer.getExtension(CameraController).dispose()
this.viewer.dispose()
this.viewer = null
}
}
const createViewer = async (parent: HTMLElement): Promise<Viewer> => {
const viewerSettings = DefaultViewerParams
viewerSettings.showStats = false
viewerSettings.verbose = true // Turning this on so we can see logs for now
const viewer = new Viewer(parent, viewerSettings)
await viewer.init()
viewer.createExtension(HybridCameraController) // camera controller
viewer.createExtension(SelectionExtension) // selection helper
// viewer.createExtension(SectionTool) // section tool, possibly not needed for now?
// viewer.createExtension(SectionOutlines) // section tool, possibly not needed for now?
// viewer.createExtension(MeasurementsExtension) // measurements, possibly not needed for now?
viewer.createExtension(FilteringExtension) // filtering
viewer.createExtension(ViewModes) // view modes
console.log('🎥 Viewer is created!')
return viewer
}
@@ -1,6 +1,6 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
export class CameraSettings extends fs.Card {
export class CameraSettings extends fs.SimpleCard {
public defaultView: fs.SimpleSlice = new fs.AutoDropdown({
name: 'defaultView',
displayName: 'Default View',
@@ -10,12 +10,11 @@ export enum ContextOption {
ghosted = 'ghosted',
show = 'show'
}
export class ColorSettings extends fs.Card {
export class ColorSettings extends fs.SimpleCard {
public enabled = new fs.ToggleSwitch({
name: 'enabled',
displayName: 'Enabled',
value: true,
topLevelToggle: true
value: true
})
public fill = new fs.ColorPicker({
@@ -44,7 +43,7 @@ export class ColorSettings extends fs.Card {
slices: fs.Slice[] = [this.context, this.fill]
}
export class ColorSelectorSettings extends fs.Card {
export class ColorSelectorSettings extends fs.SimpleCard {
name = 'colorSelector'
displayName = 'Color Selector'
slices = []
@@ -2,15 +2,14 @@ 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 {
export class LightingSettings extends fs.SimpleCard {
name = 'lighting'
displayName = 'Lighting'
public enabled = new fs.ToggleSwitch({
name: 'enabled',
displayName: 'Enabled',
value: true,
topLevelToggle: true
value: true
})
public intensity = new fs.Slider({
-41
View File
@@ -1,41 +0,0 @@
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)
}
}
})
+260
View File
@@ -0,0 +1,260 @@
import { CanonicalView, SpeckleView, ViewMode } from '@speckle/viewer'
import { IViewerEvents } from '@src/plugins/viewer'
import { SpeckleDataInput } from '@src/types'
import { zipJSONChunks, zipModelObjects } from '@src/utils/compression'
import { ReceiveInfo } from '@src/utils/matrixViewUtils'
import { defineStore } from 'pinia'
import { Vector3 } from 'three'
import { ref, shallowRef } from 'vue'
export type InputState = 'valid' | 'incomplete' | 'invalid'
export type FieldInputState = {
rootObjectId: boolean
objectIds: boolean
colorBy: boolean
tooltipData: boolean
}
export const useVisualStore = defineStore('visualStore', () => {
const host = shallowRef<powerbi.extensibility.visual.IVisualHost>()
const loadingProgress = ref<{ summary: string; progress: number }>(undefined)
const objectsFromStore = ref<object[]>(undefined)
// once you see this shit, you might freak out and you are right. All of them needed because of "update" function trigger by API.
// most of the time we need to know what we are doing to treat operations accordingly. Ask for more to me (Ogu), but the answers will make both of us unhappy.
const isViewerInitialized = ref<boolean>(false)
const isViewerReadyToLoad = ref<boolean>(false)
const isViewerObjectsLoaded = ref<boolean>(false)
const viewerReloadNeeded = ref<boolean>(false)
const isLoadingFromFile = ref<boolean>(false)
const receiveInfo = ref<ReceiveInfo>(undefined)
const fieldInputState = ref<FieldInputState>({
rootObjectId: false,
objectIds: false,
colorBy: false,
tooltipData: false
})
const lastLoadedRootObjectId = ref<string>()
const cameraPosition = ref<number[]>(undefined)
const defaultViewModeInFile = ref<string>(undefined)
const speckleViews = ref<SpeckleView[]>([])
// callback mechanism to viewer to be able to manage input data accordingly.
// Note: storing whole viewer in store is not make sense and also pinia ts complains about it for serialization issues.
// Error was and you can not/should not compress: 👇
// `The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed.ts(7056)`
const viewerEmit =
ref<
<E extends keyof IViewerEvents>(event: E, ...payload: Parameters<IViewerEvents[E]>) => void
>()
// TODO: investigate about shallow ref? https://vuejs.org/api/reactivity-advanced.html#shallowref
const dataInput = shallowRef<SpeckleDataInput | null>()
const dataInputStatus = ref<InputState>('incomplete')
/**
* Ideally one time setup on initialization.
* @param hostToSet interaction layer with powerbi host. it is useful when you wanna trigger `launchUrl` kind functions. TODO: need more understanding.
*/
const setHost = (hostToSet: powerbi.extensibility.visual.IVisualHost) => {
host.value = hostToSet
}
const setReceiveInfo = (newReceiveInfo: ReceiveInfo) => (receiveInfo.value = newReceiveInfo)
/**
* Ideally one time set when onMounted of `ViewerWrapper.vue` component
* @param emit picky emit function to trigger events under `IViewerEvents` interface
*/
const setViewerEmitter = (
emit: <E extends keyof IViewerEvents>(
event: E,
...payload: Parameters<IViewerEvents[E]>
) => void
) => {
if (emit) {
viewerEmit.value = emit
viewerEmit.value('ping', '✅ Emitter successfully attached to the store.')
isViewerInitialized.value = true // this is needed to be delay first load at the visual.ts file
}
}
const setObjectsFromStore = (newObjectsFromStore: object[]) => {
objectsFromStore.value = newObjectsFromStore
}
const setLoadingProgress = (summary: string, progress: number) => {
loadingProgress.value = { summary, progress }
if (loadingProgress.value.progress >= 1) {
clearLoadingProgress()
}
}
const clearLoadingProgress = () => (loadingProgress.value = undefined)
// MAKE TS HAPPY
type SpeckleObject = {
id: string
}
const loadObjectsFromFile = async (objects: object[]) => {
lastLoadedRootObjectId.value = (objects[0] as SpeckleObject).id // TODO fix
viewerReloadNeeded.value = false
console.log(`📦 Loading viewer from cached data with ${lastLoadedRootObjectId.value} id.`)
await viewerEmit.value('loadObjects', objects)
clearLoadingProgress()
objectsFromStore.value = objects
isViewerObjectsLoaded.value = true
viewerReloadNeeded.value = false
setIsLoadingFromFile(false)
}
/**
* Sets upcoming data input into store to be able to pass it through viewer by evaluating the data.
* @param newValue new data input that user dragged and dropped to the fields in visual
*/
const setDataInput = async (newValue: SpeckleDataInput) => {
dataInput.value = newValue
if (viewerReloadNeeded.value) {
const modelIds = dataInput.value.modelObjects.map((o) => (o[0] as SpeckleObject).id).join(',')
lastLoadedRootObjectId.value = modelIds
console.log(`🔄 Forcing viewer re-render for new root object id.`)
await viewerEmit.value('loadObjects', dataInput.value.modelObjects)
clearLoadingProgress()
viewerReloadNeeded.value = false
isViewerObjectsLoaded.value = true
writeObjectsToFile(dataInput.value.modelObjects)
}
if (dataInput.value.selectedIds.length > 0) {
viewerEmit.value('isolateObjects', dataInput.value.selectedIds)
} else {
viewerEmit.value('isolateObjects', dataInput.value.objectIds)
}
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
const writeObjectsToFile = (modelObjects: object[][]) => {
const compressedChunks = zipModelObjects(modelObjects, 10000) // Compress in chunks
host.value.persistProperties({
merge: [
{
objectName: 'storedData',
properties: {
speckleObjects: compressedChunks,
receiveInfo: JSON.stringify(receiveInfo.value)
},
selector: null
}
]
})
}
const writeCameraViewToFile = (view: CanonicalView) => {
host.value.persistProperties({
merge: [
{
objectName: 'camera',
properties: {
defaultView: view
},
selector: null
}
]
})
}
const writeViewModeToFile = (viewMode: ViewMode) => {
host.value.persistProperties({
merge: [
{
objectName: 'viewMode',
properties: {
defaultViewMode: viewMode
},
selector: null
}
]
})
}
const writeCameraPositionToFile = (position: Vector3, target: Vector3) => {
host.value.persistProperties({
merge: [
{
objectName: 'cameraPosition',
properties: {
positionX: position.x,
positionY: position.y,
positionZ: position.z,
targetX: target.x,
targetY: target.y,
targetZ: target.z
},
selector: null
}
]
})
}
const setFieldInputState = (newFieldInputState: FieldInputState) =>
(fieldInputState.value = newFieldInputState)
const clearDataInput = () => (dataInput.value = null)
const setIsLoadingFromFile = (newValue: boolean) => (isLoadingFromFile.value = newValue)
const setViewerReadyToLoad = () => (isViewerReadyToLoad.value = true)
const setViewerReloadNeeded = () => (viewerReloadNeeded.value = true)
const setCameraPositionInFile = (newValue: number[]) => (cameraPosition.value = newValue)
const setDefaultViewModeInFile = (newValue: string) => (defaultViewModeInFile.value = newValue)
const setSpeckleViews = (newSpeckleViews: SpeckleView[]) => (speckleViews.value = newSpeckleViews)
return {
host,
receiveInfo,
objectsFromStore,
isViewerInitialized,
isViewerReadyToLoad,
isViewerObjectsLoaded,
viewerReloadNeeded,
dataInput,
dataInputStatus,
viewerEmit,
fieldInputState,
lastLoadedRootObjectId,
loadingProgress,
isLoadingFromFile,
cameraPosition,
defaultViewModeInFile,
speckleViews,
setCameraPositionInFile,
setDefaultViewModeInFile,
setSpeckleViews,
loadObjectsFromFile,
setHost,
setReceiveInfo,
setViewerReloadNeeded,
setObjectsFromStore,
writeObjectsToFile,
writeCameraViewToFile,
writeViewModeToFile,
writeCameraPositionToFile,
setViewerEmitter,
setDataInput,
setFieldInputState,
clearDataInput,
setViewerReadyToLoad,
setLoadingProgress,
clearLoadingProgress,
setIsLoadingFromFile
}
})
+2 -1
View File
@@ -10,10 +10,11 @@ export interface IViewerTooltip {
}
export interface SpeckleDataInput {
objectsToLoad: string[]
modelObjects: object[][]
objectIds: string[]
selectedIds: string[]
colorByIds: { objectIds: string[]; slice: fs.ColorPicker; color: string }[]
objectTooltipData: Map<string, IViewerTooltip>
view: powerbi.DataViewMatrix
isFromStore: boolean
}
@@ -0,0 +1,89 @@
import pako from 'pako'
function arrayBufferToBase64(buffer) {
let binary = ''
const bytes = new Uint8Array(buffer)
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes
}
/**
* Splits an array into smaller chunks.
*/
function chunkArray(array, chunkSize) {
const chunks = []
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize))
}
return chunks
}
export function zipModelObjects(modelObjects: object[][], chunkSize = 1000) {
return modelObjects.map((objects) => zipJSONChunks(objects, chunkSize)).join('>')
}
/**
* Compresses JSON objects in chunks properly.
*/
export function zipJSONChunks(objectsInModel: object[], chunkSize = 1000) {
const chunks = chunkArray(objectsInModel, chunkSize)
return chunks.map((chunk, index) => {
const jsonString = JSON.stringify(chunk)
const originalSize = new TextEncoder().encode(jsonString).length / (1024 * 1024) // Original size in bytes
const compressed = pako.deflate(jsonString) // Returns Uint8Array
const compressedBase64 = btoa(arrayBufferToBase64(compressed))
const compressedSize = new TextEncoder().encode(compressedBase64).length / (1024 * 1024) // Compressed size in bytes
console.log(
`Chunk ${
index + 1
}: Original Size = ${originalSize} MB, Compressed Size = ${compressedSize} MB`
)
return compressedBase64
})
}
export function unzipModelObjects(compressedChunk: string) {
const compressedModelObjects = compressedChunk.split('>')
console.log(compressedModelObjects)
return compressedModelObjects.map((compressedModelObjs) =>
unzipJSONChunk(compressedModelObjs.split(','))
)
}
/**
* Decompresses JSON chunks properly.
*/
export function unzipJSONChunks(compressedChunks) {
return compressedChunks.flatMap((compressedStr) => {
const binaryString = atob(compressedStr) // Decode from Base64
const byteArray = base64ToArrayBuffer(binaryString)
const decompressed = pako.inflate(byteArray, { to: 'string' })
return JSON.parse(decompressed)
})
}
/**
* Decompresses a single JSON chunk properly.
*/
export function unzipJSONChunk(compressedChunk) {
const binaryString = atob(compressedChunk) // Decode from Base64
const byteArray = base64ToArrayBuffer(binaryString)
const decompressed = pako.inflate(byteArray, { to: 'string' })
return JSON.parse(decompressed)
}
@@ -0,0 +1,55 @@
/**
* @param appname application name with its version, i.e. `Rhino 7`, `Revit 2024`
* @returns slug
*/
export function getSlugFromHostAppNameAndVersion(appname: string) {
if (!appname) {
return 'other'
}
// delete space if any
appname = appname.toLowerCase().replace(/\s/g, '')
// `NEW CONNECTOR CHECK`
const keywords = [
'dynamo',
'revit',
'autocad',
'civil',
'rhino',
'grasshopper',
'unity',
'gsa',
'microstation',
'openroads',
'openrail',
'openbuildings',
'etabs',
'sap',
'csibridge',
'safe',
'teklastructures',
'dxf',
'excel',
'unreal',
'powerbi',
'blender',
'qgis',
'arcgis',
'sketchup',
'archicad',
'topsolid',
'python',
'net',
'navisworks',
'advancesteel'
]
for (const keyword of keywords) {
if (appname.includes(keyword)) {
return keyword
}
}
return appname
}
@@ -1,7 +0,0 @@
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
}
+247 -95
View File
@@ -7,34 +7,57 @@ import {
} from 'powerbi-visuals-utils-dataviewutils/lib/dataViewWildcard'
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions
import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
import { FieldInputState, useVisualStore } from '@src/store/visualStore'
import { delay } from 'lodash'
import { getSlugFromHostAppNameAndVersion } from './hostAppSlug'
export function validateMatrixView(options: VisualUpdateOptions): {
hasColorFilter: boolean
view: powerbi.DataViewMatrix
} {
export class AsyncPause {
private lastPauseTime = 0
public needsWait = false
public tick(maxDelta: number) {
const now = performance.now()
const delta = now - this.lastPauseTime
// console.log('Delta -> ', delta)
if (delta > maxDelta) {
this.needsWait = true
}
}
public async wait(waitTime: number) {
this.lastPauseTime = performance.now()
await new Promise((resolve) => setTimeout(resolve, waitTime))
this.needsWait = false
}
}
export function validateMatrixView(options: VisualUpdateOptions): FieldInputState {
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
let hasRootObjectId = false,
hasObjectIds = false,
hasColorFilter = false,
hasTooltipData = 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 (!hasRootObjectId) hasRootObjectId = source.roles['rootObjectId'] != undefined
if (!hasObjectIds) hasObjectIds = source.roles['objectIds'] != 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')
matrixVew.columns.levels.forEach((level) => {
level.sources.forEach((source) => {
if (!hasTooltipData) hasTooltipData = source.roles['tooltipData'] != undefined
})
})
return {
hasColorFilter,
view: matrixVew
rootObjectId: hasRootObjectId,
objectIds: hasObjectIds,
colorBy: hasColorFilter,
tooltipData: hasTooltipData
}
}
@@ -109,9 +132,7 @@ function processObjectIdLevel(
host: powerbi.extensibility.visual.IVisualHost,
matrixView: powerbi.DataViewMatrix
) {
return parentObjectIdChild.children?.map((objectIdChild) =>
processObjectNode(objectIdChild, host, matrixView)
)
return processObjectNode(parentObjectIdChild, host, matrixView)
}
export let previousPalette = null
@@ -119,105 +140,236 @@ export let previousPalette = null
export function resetPalette() {
previousPalette = null
}
export function processMatrixView(
export type ReceiveInfo = {
userEmail: string
serverUrl: string
sourceApplication?: string
}
async function getReceiveInfo(id) {
try {
const ids = (id as string).split(',')
const response = await fetch(`http://localhost:29364/user-info/${ids[0]}`)
if (!response.body) {
console.error('No response body')
return
}
return await response.json()
} catch (error) {
console.log(error)
console.log("User infp couldn't retrieved from local server.")
}
}
async function fetchStreamedData(commaSeparatedModelIds: string) {
const modelIds = (commaSeparatedModelIds as string).split(',')
const modelObjects = []
for await (const id of modelIds) {
const objects = await fetchStreamedDataForModel(id)
modelObjects.push(objects)
}
return modelObjects
}
async function fetchStreamedDataForModel(id) {
try {
const response = await fetch(`http://localhost:29364/get-objects/${id}`)
if (!response.body) {
console.error('No response body')
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
const objects = []
let buffer = ''
const start = performance.now()
console.log('Streaming started...')
for await (const chunk of readStream(reader)) {
// chucks.push(chuck)
buffer += decoder.decode(chunk, { stream: true })
let boundary
while ((boundary = buffer.indexOf('\n')) !== -1) {
const jsonString = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 1)
try {
const obj = JSON.parse(jsonString)
objects.push(obj)
// console.log('Received object:', jsonObject)
} catch (e) {
console.error('Invalid JSON chunk:', jsonString)
}
}
}
try {
const obj = JSON.parse(buffer)
objects.push(obj)
// console.log('Received object:', jsonObject)
} catch (e) {
console.error('Invalid JSON chunk:', buffer)
}
const end = performance.now()
console.log(`Objects streamed in: ${(end - start) / 1000} s`)
const startObjectCleanup = performance.now()
// Skips first element
for (let i = 1; i < objects.length; i++) {
const obj = objects[i]
if (obj.speckle_type) {
if (obj.speckle_type.includes('Objects.Data.DataObject')) {
delete obj.properties
}
}
delete obj.__closure
}
const endObjectCleanup = performance.now()
console.log(`Objects cleaned up in: ${(endObjectCleanup - startObjectCleanup) / 1000} s`)
const sizeInBytes = new TextEncoder().encode(JSON.stringify(objects)).length
const sizeInMB = sizeInBytes / (1024 * 1024)
console.log(`Size of objects: ${sizeInMB} MB`)
return objects
} catch (error) {
console.log(error)
console.log("Objects couldn't retrieved from local server.")
} finally {
console.log('Streaming finished!')
}
}
async function* readStream(reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
yield value
}
}
export async 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 = [],
): Promise<SpeckleDataInput> {
const visualStore = useVisualStore()
const objectIds = [],
selectedIds = [],
colorByIds = [],
objectTooltipData = new Map<string, IViewerTooltip>()
matrixView.rows.root.children.forEach((streamUrlChild) => {
const url = streamUrlChild.value
console.log('🪜 Processing Matrix View', matrixView)
streamUrlChild.children?.forEach((parentObjectIdChild) => {
const parentId = parentObjectIdChild.value
objectUrlsToLoad.push(`${url}/objects/${parentId}`)
const localMatrixView = matrixView.rows.root.children[0]
const id = localMatrixView.value as unknown as string
console.log('🗝️ Root Object Id: ', id)
console.log('Last laoded root object id', visualStore.lastLoadedRootObjectId)
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()
let modelObjects: object[][] = undefined
const color = host.colorPalette.getColor(colorByChild.value as string)
if (colorByChild.objects) {
console.log(
'⚠️COLOR NODE HAS objects',
colorByChild.objects,
colorByChild.objects.color?.fill
)
}
if (visualStore.isLoadingFromFile) {
console.log('The data is loading from file, skipping the streaming it.')
}
const colorSlice = new fs.ColorPicker({
name: 'selectorFill',
displayName: colorByChild.value.toString(),
value: {
value: color.value
},
selector: colorSelectionId.getSelector()
})
if (visualStore.lastLoadedRootObjectId !== id && !visualStore.isLoadingFromFile) {
const start = performance.now()
visualStore.setViewerReadyToLoad()
visualStore.setLoadingProgress('Loading', null)
const colorGroup = {
color: color.value,
slice: colorSlice,
objectIds: []
}
// stream data
modelObjects = await fetchStreamedData(id)
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)
})
}
const receiveInfo = await getReceiveInfo(id)
if (receiveInfo) {
visualStore.setReceiveInfo({
userEmail: receiveInfo.email,
serverUrl: receiveInfo.server,
sourceApplication: getSlugFromHostAppNameAndVersion(receiveInfo.sourceApplication)
})
}
visualStore.setViewerReloadNeeded() // they should be marked as deferred action bc of update function complexity.
console.log(`🚀 Upload is completed in ${(performance.now() - start) / 1000} s!`)
}
// NOTE: matrix view gave us already filtered out rows from tooltip data if it is assigned
localMatrixView.children?.forEach((obj) => {
// otherwise there is no point to collect objects
const processedObjectIdLevels = processObjectIdLevel(obj, host, matrixView)
objectIds.push(processedObjectIdLevels.id)
onSelectionPair(processedObjectIdLevels.id, processedObjectIdLevels.selectionId)
if (processedObjectIdLevels.shouldSelect) {
selectedIds.push(processedObjectIdLevels.id)
}
objectTooltipData.set(processedObjectIdLevels.id, {
selectionId: processedObjectIdLevels.selectionId,
data: processedObjectIdLevels.data
})
if (hasColorFilter) {
if (previousPalette) host.colorPalette['colorPalette'] = previousPalette
obj.children.forEach((child) => {
const colorSelectionId = host
.createSelectionIdBuilder()
.withMatrixNode(child, matrixView.rows.levels)
.createSelectionId()
const color = host.colorPalette.getColor(child.values[0].value as string)
const colorSlice = new fs.ColorPicker({
name: 'selectorFill',
displayName: child.value?.toString(),
value: {
value: color.value
},
selector: colorSelectionId.getSelector()
})
const colorGroup = {
color: color.value,
slice: colorSlice,
objectIds: []
}
const processedObjectIdLevels = processObjectIdLevel(child, host, matrixView)
objectIds.push(processedObjectIdLevels.id)
onSelectionPair(processedObjectIdLevels.id, processedObjectIdLevels.selectionId)
if (processedObjectIdLevels.shouldSelect) selectedIds.push(processedObjectIdLevels.id)
if (processedObjectIdLevels.shouldColor) {
colorGroup.objectIds.push(processedObjectIdLevels.id)
}
objectTooltipData.set(processedObjectIdLevels.id, {
selectionId: processedObjectIdLevels.selectionId,
data: processedObjectIdLevels.data
})
if (colorGroup.objectIds.length > 0) colorByIds.push(colorGroup)
})
}
})
previousPalette = host.colorPalette['colorPalette']
return {
objectsToLoad: objectUrlsToLoad,
modelObjects,
objectIds,
selectedIds,
colorByIds: colorByIds.length > 0 ? colorByIds : null,
objectTooltipData,
view: matrixView
view: matrixView,
isFromStore: false
}
}
+181
View File
@@ -0,0 +1,181 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Lightweight MD5 implementation.
* @see http://www.myersdaily.org/joseph/javascript/md5-text.html
*/
function md5cycle(x: any, k: any) {
let a = x[0],
b = x[1],
c = x[2],
d = x[3]
a = ff(a, b, c, d, k[0], 7, -680876936)
d = ff(d, a, b, c, k[1], 12, -389564586)
c = ff(c, d, a, b, k[2], 17, 606105819)
b = ff(b, c, d, a, k[3], 22, -1044525330)
a = ff(a, b, c, d, k[4], 7, -176418897)
d = ff(d, a, b, c, k[5], 12, 1200080426)
c = ff(c, d, a, b, k[6], 17, -1473231341)
b = ff(b, c, d, a, k[7], 22, -45705983)
a = ff(a, b, c, d, k[8], 7, 1770035416)
d = ff(d, a, b, c, k[9], 12, -1958414417)
c = ff(c, d, a, b, k[10], 17, -42063)
b = ff(b, c, d, a, k[11], 22, -1990404162)
a = ff(a, b, c, d, k[12], 7, 1804603682)
d = ff(d, a, b, c, k[13], 12, -40341101)
c = ff(c, d, a, b, k[14], 17, -1502002290)
b = ff(b, c, d, a, k[15], 22, 1236535329)
a = gg(a, b, c, d, k[1], 5, -165796510)
d = gg(d, a, b, c, k[6], 9, -1069501632)
c = gg(c, d, a, b, k[11], 14, 643717713)
b = gg(b, c, d, a, k[0], 20, -373897302)
a = gg(a, b, c, d, k[5], 5, -701558691)
d = gg(d, a, b, c, k[10], 9, 38016083)
c = gg(c, d, a, b, k[15], 14, -660478335)
b = gg(b, c, d, a, k[4], 20, -405537848)
a = gg(a, b, c, d, k[9], 5, 568446438)
d = gg(d, a, b, c, k[14], 9, -1019803690)
c = gg(c, d, a, b, k[3], 14, -187363961)
b = gg(b, c, d, a, k[8], 20, 1163531501)
a = gg(a, b, c, d, k[13], 5, -1444681467)
d = gg(d, a, b, c, k[2], 9, -51403784)
c = gg(c, d, a, b, k[7], 14, 1735328473)
b = gg(b, c, d, a, k[12], 20, -1926607734)
a = hh(a, b, c, d, k[5], 4, -378558)
d = hh(d, a, b, c, k[8], 11, -2022574463)
c = hh(c, d, a, b, k[11], 16, 1839030562)
b = hh(b, c, d, a, k[14], 23, -35309556)
a = hh(a, b, c, d, k[1], 4, -1530992060)
d = hh(d, a, b, c, k[4], 11, 1272893353)
c = hh(c, d, a, b, k[7], 16, -155497632)
b = hh(b, c, d, a, k[10], 23, -1094730640)
a = hh(a, b, c, d, k[13], 4, 681279174)
d = hh(d, a, b, c, k[0], 11, -358537222)
c = hh(c, d, a, b, k[3], 16, -722521979)
b = hh(b, c, d, a, k[6], 23, 76029189)
a = hh(a, b, c, d, k[9], 4, -640364487)
d = hh(d, a, b, c, k[12], 11, -421815835)
c = hh(c, d, a, b, k[15], 16, 530742520)
b = hh(b, c, d, a, k[2], 23, -995338651)
a = ii(a, b, c, d, k[0], 6, -198630844)
d = ii(d, a, b, c, k[7], 10, 1126891415)
c = ii(c, d, a, b, k[14], 15, -1416354905)
b = ii(b, c, d, a, k[5], 21, -57434055)
a = ii(a, b, c, d, k[12], 6, 1700485571)
d = ii(d, a, b, c, k[3], 10, -1894986606)
c = ii(c, d, a, b, k[10], 15, -1051523)
b = ii(b, c, d, a, k[1], 21, -2054922799)
a = ii(a, b, c, d, k[8], 6, 1873313359)
d = ii(d, a, b, c, k[15], 10, -30611744)
c = ii(c, d, a, b, k[6], 15, -1560198380)
b = ii(b, c, d, a, k[13], 21, 1309151649)
a = ii(a, b, c, d, k[4], 6, -145523070)
d = ii(d, a, b, c, k[11], 10, -1120210379)
c = ii(c, d, a, b, k[2], 15, 718787259)
b = ii(b, c, d, a, k[9], 21, -343485551)
x[0] = add32(a, x[0])
x[1] = add32(b, x[1])
x[2] = add32(c, x[2])
x[3] = add32(d, x[3])
}
function cmn(q: any, a: any, b: any, x: any, s: any, t: any) {
a = add32(add32(a, q), add32(x, t))
return add32((a << s) | (a >>> (32 - s)), b)
}
function ff(a: any, b: any, c: any, d: any, x: any, s: any, t: any) {
return cmn((b & c) | (~b & d), a, b, x, s, t)
}
function gg(a: any, b: any, c: any, d: any, x: any, s: any, t: any) {
return cmn((b & d) | (c & ~d), a, b, x, s, t)
}
function hh(a: any, b: any, c: any, d: any, x: any, s: any, t: any) {
return cmn(b ^ c ^ d, a, b, x, s, t)
}
function ii(a: any, b: any, c: any, d: any, x: any, s: any, t: any) {
return cmn(c ^ (b | ~d), a, b, x, s, t)
}
function md51(s: any) {
const n = s.length,
state = [1732584193, -271733879, -1732584194, 271733878]
let i
for (i = 64; i <= s.length; i += 64) {
md5cycle(state, md5blk(s.substring(i - 64, i)))
}
s = s.substring(i - 64)
const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
for (i = 0; i < s.length; i++) tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3)
tail[i >> 2] |= 0x80 << (i % 4 << 3)
if (i > 55) {
md5cycle(state, tail)
for (i = 0; i < 16; i++) tail[i] = 0
}
tail[14] = n * 8
md5cycle(state, tail)
return state
}
function md5blk(s: any) {
/* I figured global was faster. */
const md5blks = []
let i
for (i = 0; i < 64; i += 4) {
md5blks[i >> 2] =
s.charCodeAt(i) +
(s.charCodeAt(i + 1) << 8) +
(s.charCodeAt(i + 2) << 16) +
(s.charCodeAt(i + 3) << 24)
}
return md5blks
}
const HEX_CHR = '0123456789abcdef'.split('')
function rhex(n: any) {
let s = '',
j = 0
for (; j < 4; j++) s += HEX_CHR[(n >> (j * 8 + 4)) & 0x0f] + HEX_CHR[(n >> (j * 8)) & 0x0f]
return s
}
function hex(x: any) {
for (let i = 0; i < x.length; i++) x[i] = rhex(x[i])
return x.join('')
}
let add32 = (a: any, b: any) => {
return (a + b) & 0xffffffff
}
/**
* Generate an MD5 hash for an input string
* @param {string} s input string
* @returns {string} md5 hash
*/
export function md5(s: string): string {
return hex(md51(s))
}
if (md5('hello') !== '5d41402abc4b2a76b9719d911017c592') {
add32 = (x, y) => {
const lsw = (x & 0xffff) + (y & 0xffff),
msw = (x >> 16) + (y >> 16) + (lsw >> 16)
return (msw << 16) | (lsw & 0xffff)
}
}
+34 -20
View File
@@ -1,12 +1,13 @@
// TBD NOTE: if we decide to tackle certification on visual and deploy it on microsoft marketplace, we would need to remove this logic
// since we enable webaccess privile for the sake of mixpanel for now.
import { useVisualStore } from '@src/store/visualStore'
import { md5 } from './md5'
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'
}
const IS_OFFLINE_SUPPORT = true
export enum SettingsChangedType {
Gradient = 'Gradient',
@@ -15,16 +16,34 @@ export enum SettingsChangedType {
}
export class Tracker {
public static async track(event: Event, properties: any = {}) {
public static async track(event: string, properties: any = {}) {
const visualStore = useVisualStore()
const receiveInfo = visualStore.receiveInfo
let tempProperties = properties
if (receiveInfo) {
const hashedEmail = '@' + md5(receiveInfo.userEmail.toLowerCase() as string).toUpperCase()
const hashedServer = md5(
new URL(receiveInfo.serverUrl).hostname.toLowerCase() as string
).toUpperCase()
tempProperties = {
...tempProperties, // eslint-disable-next-line camelcase
distinct_id: hashedEmail,
// eslint-disable-next-line camelcase
server_id: hashedServer,
email: receiveInfo.userEmail,
isAnonymous: receiveInfo.userEmail === ''
}
}
return this.trackEvents([
{
event,
properties
properties: tempProperties
}
])
}
private static async trackEvents(events: Array<{ event: Event; properties: any }>) {
private static async trackEvents(events: Array<{ event: string; properties: any }>) {
try {
await fetch(TRACK_URL, {
method: 'POST',
@@ -34,7 +53,10 @@ export class Tracker {
events.map((e) => {
Object.assign(e.properties, {
token: MIXPANEL_TOKEN,
hostApp: HOST_APP_NAME
hostApp: HOST_APP_NAME,
offlineSupport: IS_OFFLINE_SUPPORT,
ui: 'dui3',
type: 'action'
})
return e
})
@@ -45,15 +67,7 @@ export class Tracker {
}
}
public static loaded() {
return this.track(Event.Create)
}
public static dataReload() {
return this.track(Event.Reload)
}
public static settingsChanged(type: SettingsChangedType) {
return this.track(Event.Settings, { type })
public static dataLoaded(properties = {}) {
return this.track('Receive', properties)
}
}
+62 -24
View File
@@ -1,33 +1,71 @@
<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"
class="flex flex-col justify-center items-center h-full w-full text-center space-y-4 p-2"
>
<div class="flex justify-center items-center">
<img src="@assets/logo-white.png" alt="Logo" class="w-1/3" />
<div class="flex flex-col justify-center items-center h-full w-full text-center space-y-4">
<div class="flex flex-row justify-center items-center space-x-3">
<div class="flex justify-center items-center">
<img src="@assets/logo-big.png" alt="Logo" class="w-12" />
</div>
<div
class="bg-gradient-to-r from-blue-500 via-blue-400 to-blue-600 inline-block py-1 text-transparent bg-clip-text text-2xl"
>
<p>
<b>Speckle</b>
for
<b>PowerBI</b>
</p>
</div>
</div>
<div class="flex flex-col justify-center space-y-1">
<div class="flex flex-row space-x-2">
<EyeIcon class="w-6"></EyeIcon>
<p><b>Version Object ID</b></p>
<ArrowRightIcon class="w-4"></ArrowRightIcon>
<p>View your model</p>
</div>
<div class="flex flex-row space-x-2">
<CursorArrowRippleIcon class="w-6"></CursorArrowRippleIcon>
<p><b>Object IDs</b></p>
<ArrowRightIcon class="w-4"></ArrowRightIcon>
<p>Highlighting and interactivity</p>
</div>
<div class="flex flex-row space-x-2">
<ChatBubbleLeftIcon class="w-6"></ChatBubbleLeftIcon>
<p><b>Tooltip Data</b></p>
<ArrowRightIcon class="w-4"></ArrowRightIcon>
<p>Tooltip and interactivity</p>
</div>
</div>
</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 class="flex justify-end gap-1">
<button :class="buttonClass" @click="goToGuide">Getting started</button>
<button :class="buttonClass" @click="goToForum">Help</button>
</div>
</div>
</template>
<style scoped></style>
<script setup lang="ts">
import { useVisualStore } from '../store/visualStore'
import {
EyeIcon,
ArrowRightIcon,
CursorArrowRippleIcon,
ChatBubbleLeftIcon
} from '@heroicons/vue/24/outline'
const visualStore = useVisualStore()
const buttonClass = `btn p-2 rounded-md bg-primary-muted border-transparent font-medium
hover:bg-gray-200 hover:shadow-md
disabled:hover:bg-transparent focus-visible:border-foundation transition-all duration-150`
function goToForum() {
visualStore.host.launchUrl('https://speckle.community/tag/powerbi')
}
function goToGuide() {
visualStore.host.launchUrl('https://speckle.guide/user/powerbi')
}
</script>
+58 -28
View File
@@ -1,35 +1,65 @@
<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
class="absolute top-0 left-0 z-10 cursor-pointer flex items-center"
@click="goToSpeckleWebsite"
>
<img class="w-8 h-auto mx-2 my-1" src="@assets/logo-big.png" />
<div class="font-medium">Speckle</div>
</div>
<div
v-if="isInteractive"
class="absolute top-2 left-1/2 -translate-x-1/2 z-20 bg-white bg-opacity-70 text-black text-center text-sm px-4 py-2 rounded shadow"
>
<div v-if="bothFieldsMissing">
<strong>Object IDs</strong>
and
<strong>Tooltip Data</strong>
fields are needed for interactivity with other visuals.
</div>
<div v-else-if="onlyObjectIdsMissing">
<strong>Object IDs</strong>
field is needed for interactivity with other visuals.
</div>
<div v-else-if="onlyTooltipDataMissing">
<strong>Tooltip Data</strong>
field is needed for interactivity with other visuals.
</div>
</div>
<div
v-if="visualStore.loadingProgress"
class="absolute top-1/2 left-1/2 w-1/2 -translate-x-1/2 z-20 text-center text-sm"
>
<!-- Progress Bar -->
<LoadingBar :loading="!!visualStore.loadingProgress"></LoadingBar>
</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;
}
<script setup lang="ts">
import ViewerWrapper from 'src/components/ViewerWrapper.vue'
import { useVisualStore } from '../store/visualStore'
import { computed } from 'vue'
import LoadingBar from '@src/components/loading/LoadingBar.vue'
.watermark:hover {
cursor: pointer;
}
const visualStore = useVisualStore()
#overlay {
position: absolute;
width: 100%;
z-index: 100;
}
</style>
const onlyObjectIdsMissing = computed(
() => !visualStore.fieldInputState.objectIds && visualStore.fieldInputState.tooltipData
)
const onlyTooltipDataMissing = computed(
() => visualStore.fieldInputState.objectIds && !visualStore.fieldInputState.tooltipData
)
const bothFieldsMissing = computed(
() => !visualStore.fieldInputState.objectIds && !visualStore.fieldInputState.tooltipData
)
const isInteractive = computed(
() => !visualStore.fieldInputState.objectIds || !visualStore.fieldInputState.tooltipData
)
const goToSpeckleWebsite = () => visualStore.host.launchUrl('https://speckle.systems')
</script>
+133 -49
View File
@@ -1,16 +1,13 @@
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 { selectionHandlerKey, tooltipHandlerKey } from 'src/injectionKeys'
import { Tracker } from './utils/mixpanel'
import { SpeckleDataInput } from './types'
import { processMatrixView, validateMatrixView } from './utils/matrixViewUtils'
import { processMatrixView, ReceiveInfo, validateMatrixView } from './utils/matrixViewUtils'
import { SpeckleVisualSettingsModel } from './settings/visualSettingsModel'
import TooltipHandler from './handlers/tooltipHandler'
@@ -20,24 +17,25 @@ import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructor
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions
import IVisual = powerbi.extensibility.visual.IVisual
import ITooltipService = powerbi.extensibility.ITooltipService
import {
createDataViewWildcardSelector,
DataViewWildcardMatchingOption
} from 'powerbi-visuals-utils-dataviewutils/lib/dataViewWildcard'
import { ColorSelectorSettings } from 'src/settings/colorSettings'
import { pinia } from './plugins/pinia'
import { useVisualStore } from './store/visualStore'
import { unzipModelObjects } from './utils/compression'
// noinspection JSUnusedGlobalSymbols
export class Visual implements IVisual {
private readonly host: powerbi.extensibility.visual.IVisualHost
private selectionHandler: SelectionHandler
private tooltipHandler: TooltipHandler
private isFirstViewerLoad: boolean
private formattingSettings: SpeckleVisualSettingsModel
private formattingSettingsService: FormattingSettingsService
// noinspection JSUnusedGlobalSymbols
public constructor(options: VisualConstructorOptions) {
Tracker.loaded()
this.isFirstViewerLoad = true
// Tracker.loaded()
this.host = options.host
this.formattingSettingsService = new FormattingSettingsService()
@@ -47,66 +45,132 @@ export class Visual implements IVisual {
console.log('🚀 Init Vue App')
createApp(App)
.use(store, storeKey)
.use(pinia)
// .use(store, storeKey)
.provide(selectionHandlerKey, this.selectionHandler)
.provide(tooltipHandlerKey, this.tooltipHandler)
.provide(hostKey, options.host)
.mount(options.element)
// set `host` to visual store to be able use later in other components if needed
const visualStore = useVisualStore()
visualStore.setHost(this.host)
this.host.refreshHostData() // to be able to trigger `update` function after constructor! by this way i was able to trigger viewer load objects from properties store
}
private async clear() {
this.selectionHandler.clear()
}
public update(options: VisualUpdateOptions) {
public async update(options: VisualUpdateOptions) {
const visualStore = useVisualStore()
// @ts-ignore
console.log('⤴️ Update type 👉', powerbi.VisualUpdateType[options.type])
this.formattingSettings = this.formattingSettingsService.populateFormattingSettingsModel(
SpeckleVisualSettingsModel,
options.dataViews
options.dataViews[0]
)
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)
const matrixView = options.dataViews[0].matrix
if (!matrixView) throw new Error('Data does not contain a matrix data view') // TODO: Could be toast notificiation too!
// we first need to check which inputs user provided to decide our strategy
const validationResult = validateMatrixView(options)
visualStore.setFieldInputState(validationResult)
console.log('❓Field inputs', validationResult)
switch (options.type) {
case powerbi.VisualUpdateType.Resize:
case powerbi.VisualUpdateType.ResizeEnd:
case powerbi.VisualUpdateType.Style:
case powerbi.VisualUpdateType.ViewMode:
case powerbi.VisualUpdateType.Resize + powerbi.VisualUpdateType.ResizeEnd:
return
case powerbi.VisualUpdateType.Data:
try {
// read saved data from file if any
if (
!visualStore.isViewerObjectsLoaded &&
this.isFirstViewerLoad &&
options.dataViews[0].metadata.objects
) {
const chunks = options.dataViews[0].metadata.objects.storedData
?.speckleObjects as string
const objectsFromFile = unzipModelObjects(chunks)
if (options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string) {
console.log(
`Default View Mode: ${
options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string
}`
)
visualStore.setDefaultViewModeInFile(
options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string
)
}
if (options.dataViews[0].metadata.objects.cameraPosition?.positionX as string) {
visualStore.setCameraPositionInFile([
Number(options.dataViews[0].metadata.objects.cameraPosition?.positionX),
Number(options.dataViews[0].metadata.objects.cameraPosition?.positionY),
Number(options.dataViews[0].metadata.objects.cameraPosition?.positionZ),
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetX),
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetY),
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetZ)
])
}
// get receive info from file for mixpanel
try {
const receiveInfoFromFile = JSON.parse(
options.dataViews[0].metadata.objects.storedData?.receiveInfo as string
) as ReceiveInfo
visualStore.setReceiveInfo(receiveInfoFromFile)
} catch (error) {
console.warn(error)
console.log('missing mixpanel info')
}
if (visualStore.lastLoadedRootObjectId !== objectsFromFile[0].id) {
this.tryReadFromFile(objectsFromFile, visualStore)
}
}
const input = await processMatrixView(
matrixView,
this.host,
validationResult.colorBy,
this.formattingSettings,
(obj, id) => this.selectionHandler.set(obj, id)
)
this.updateViewer(input)
} catch (error) {
console.error('Data update error', error ?? 'Unknown')
}
break
default:
return
}
} 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.`
`"Viewer Data" and "Object IDs" 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.`
`Incomplete data input. "Viewer Data", "Object IDs" data inputs are mandatory. If your data connector does not output all these columns, please update it.`
)
store.commit('setStatus', 'incomplete')
visualStore.setFieldInputState({
rootObjectId: false,
objectIds: false,
colorBy: false,
tooltipData: false
})
return
}
switch (options.type) {
case powerbi.VisualUpdateType.Resize:
case powerbi.VisualUpdateType.ResizeEnd:
case powerbi.VisualUpdateType.Style:
case powerbi.VisualUpdateType.ViewMode:
case powerbi.VisualUpdateType.Resize + powerbi.VisualUpdateType.ResizeEnd:
return
default:
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')
}
}
}
public getFormattingModel(): powerbi.visuals.FormattingModel {
console.log('Showing Formatting settings', this.formattingSettings)
const model = this.formattingSettingsService.buildFormattingModel(this.formattingSettings)
@@ -114,12 +178,32 @@ export class Visual implements IVisual {
return model
}
private throttleUpdate = _.throttle((input: SpeckleDataInput) => {
private updateViewer(input: SpeckleDataInput) {
const visualStore = useVisualStore()
this.tooltipHandler.setup(input.objectTooltipData)
store.commit('setInput', input)
store.commit('setStatus', 'valid')
store.commit('setSettings', this.formattingSettings)
}, 500)
visualStore.setViewerReadyToLoad()
if (visualStore.isViewerInitialized && !visualStore.viewerReloadNeeded) {
visualStore.setDataInput(input)
} else {
// we should give some time to Vue to render ViewerWrapper component to be able to have proper emitter setup. Happiness level 6/10
setTimeout(() => {
visualStore.setDataInput(input)
// visualStore.writeObjectsToFile(input.objects)
}, 250)
}
}
private tryReadFromFile(objectsFromFile: object[], visualStore) {
visualStore.setViewerReadyToLoad()
visualStore.setIsLoadingFromFile(true) // to block unnecessary streaming data if bg service is running
setTimeout(() => {
visualStore.loadObjectsFromFile(objectsFromFile)
this.isFirstViewerLoad = false
}, 250)
console.log(`${objectsFromFile.length} objects retrieved from persistent properties!`)
}
public async destroy() {
await this.clear()
+1
View File
@@ -7,6 +7,7 @@
"sourceMap": true,
"outDir": "./.tmp/build/",
"moduleResolution": "node",
"skipLibCheck": true,
"declaration": true,
"lib": ["es2020", "dom"],
"allowSyntheticDefaultImports": true,
+269
View File
@@ -0,0 +1,269 @@
/* 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'
/**
* MAIN CONSTS
*/
const devServerPort = 8080
const pbivizPath = './pbiviz.json'
const capabilitiesPath = './capabilities.json'
const pluginLocation = './.tmp/precompile/visualPlugin.ts' // path to visual plugin file, the file generates by the 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 pbivizFile = require(path.join(__dirname, pbivizPath))
const packageJsonFile = require(path.join(__dirname, 'package.json'))
pbivizFile.visual.version = packageJsonFile.version
// the visual capabilities content
const capabilitiesFile = require(path.join(__dirname, capabilitiesPath))
// 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
}
export const buildConfig = (params: { mode: 'dev' | 'prod' }) => {
const isProd = params.mode === 'prod'
const loadCert = () => {
const keyPath = path.resolve(__dirname, 'localhost-key.pem')
const certPath = path.resolve(__dirname, 'localhost.pem')
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
console.log('Unable to locate localhost certs, skipping...')
return undefined
}
console.log(
'Using locally generated localhost certs, make sure the CA cert is installed & trusted!'
)
return {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath)
}
}
const certInfo = isProd ? undefined : loadCert()
const config: WebpackConfiguration = {
entry: {
visual: pluginLocation
},
optimization: {
concatenateModules: false,
minimize: isProd // enable minimization for create *.pbiviz file less than 2 Mb, can be disabled for dev mode
},
devtool: isProd ? false : 'inline-source-map',
mode: isProd ? 'production' : '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
},
...(isProd
? {}
: {
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: devServerPort, // dev server port
hot: false,
...(certInfo
? {
server: {
type: 'https',
options: {
...certInfo
}
}
}
: {
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: isProd ? 9 : 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: isProd,
generateResources: true,
minifyJS: isProd,
minify: isProd,
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'
})
]
}
return config
}
+2 -227
View File
@@ -1,228 +1,3 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import path from 'path'
import { buildConfig } from './webpack.config.base'
// 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))
const packageJsonFile = require(path.join(__dirname, 'package.json'))
pbivizFile.visual.version = packageJsonFile.version
// 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
export default buildConfig({ mode: 'dev' })
+2 -211
View File
@@ -1,212 +1,3 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import path from 'path'
import { buildConfig } from './webpack.config.base'
// 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))
const packageJsonFile = require(path.join(__dirname, 'package.json'))
pbivizFile.visual.version = packageJsonFile.version
// 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
export default buildConfig({ mode: 'prod' })