Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac0db18d24 | |||
| 1fff59de85 | |||
| 771e34f6b5 | |||
| f0b072d210 | |||
| 97a72c966a | |||
| 058c846770 | |||
| 3bb69f7aa8 | |||
| c1dac7e663 | |||
| 056815dad8 | |||
| 929f06dbb7 | |||
| 8cdfa97f41 | |||
| 28e9c43e5e | |||
| 380c60281b | |||
| c4f7c6082e | |||
| 51a5261f1d | |||
| 5f6a032bf8 | |||
| 8e1fe78af3 | |||
| 6dffa7dfa3 | |||
| f61fae4284 | |||
| f97946a426 | |||
| 8949917f21 | |||
| 0a0abd70df | |||
| 19c9252a4a | |||
| e50b04b305 | |||
| 5cd0fe2b12 | |||
| 59dd7ee9fc | |||
| a6e7ed86d9 | |||
| 573e70df02 | |||
| 52f406f3ac | |||
| fbe34d43b1 | |||
| 5ad8139a4c | |||
| ba43724443 | |||
| 8a34685b00 | |||
| e9c2293736 | |||
| d27adb243f | |||
| e55a382d73 | |||
| 00af5b5abb | |||
| cba5ccf254 | |||
| ae3c480f02 | |||
| 0b716eb2cd | |||
| a88f3323b1 | |||
| 7b06aef1f8 | |||
| a45a67ed38 | |||
| efd07d4ffb | |||
| 33116083e8 | |||
| 12f548bd76 | |||
| 8b9c4b1cf2 | |||
| deb34e9262 | |||
| 4a4a7d70c0 | |||
| 4e1ce9ce71 | |||
| c9c1820e4d | |||
| 62110739fb | |||
| d5527c79b1 | |||
| 79ba5cd365 | |||
| 17c857d138 | |||
| f0538c94c3 | |||
| b1f2c741ad | |||
| 64dc73411a | |||
| 8816430f51 | |||
| d5e8cd8a90 | |||
| 3bc82f6b9e | |||
| dc4f4c19f4 | |||
| 15ebfab50b | |||
| 10cc50a5b8 | |||
| 049fab417f | |||
| efe1c7ea0f | |||
| 7d7f3b04c5 | |||
| 8e03d4464e | |||
| 41161a8739 | |||
| 436d13475d | |||
| 6618e5654b | |||
| ca34cd3adc | |||
| 5168516109 | |||
| 2fa69873d1 | |||
| 4f15db5d9c | |||
| e233aec607 | |||
| 52f4325619 | |||
| 6e92c857a7 | |||
| 8edc01b7d6 | |||
| 6d5f638895 | |||
| ccecf7cb2b | |||
| 156c3a5c3a | |||
| 887dbb2344 | |||
| 556196a45f | |||
| 1bf6e76252 | |||
| b26801ef8a | |||
| 74ab91be3b |
@@ -46,10 +46,6 @@ jobs:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: build_powerbi
|
||||
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-*
|
||||
@@ -341,3 +341,6 @@ ASALocalRun/
|
||||
**/webpack.statistics.html
|
||||
**/Thumbs.db
|
||||
installer/
|
||||
|
||||
localhost.pem
|
||||
localhost-key.pem
|
||||
@@ -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
|
||||
@@ -1,14 +1,74 @@
|
||||
<h1 align="center">
|
||||
<<h1 align="center">
|
||||
<img src="https://user-images.githubusercontent.com/2679513/131189167-18ea5fe1-c578-47f6-9785-3748178e4312.png" width="150px"/><br/>
|
||||
Speckle | PowerBI
|
||||
Speckle | Power BI
|
||||
</h1>
|
||||
<h3 align="center">
|
||||
Data Connector and 3D Viewer Visual for PowerBI platform
|
||||
</h3>
|
||||
<p align="center"><b>Speckle</b> is the data infrastructure for the AEC industry.</p><br/>
|
||||
|
||||
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&style=flat-square&logo=discourse&logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&logo=read-the-docs&logoColor=white" alt="docs"></a></p>
|
||||
<p align="center"></p>
|
||||
|
||||
> Speckle is the first AEC data hub that connects with your favorite AEC tools. Speckle exists to overcome the challenges of working in a fragmented industry where communication, creative workflows, and the exchange of data are often hindered by siloed software and processes. It is here to make the industry better.
|
||||
|
||||
<h3 align="center">
|
||||
Speckle Connector and 3D Viewer Visual for Power BI
|
||||
</h3>
|
||||
|
||||
# Features
|
||||
|
||||
Speckle Power BI Data Connector lets you easily get data from Speckle into Power BI reports and visualizations. You can access and analyze data from various AEC apps (like Revit, Archicad, Grasshopper, and more) and open-source files (IFC, STL, OBJ, etc.) into Power BI with ease.
|
||||
|
||||
Speckle’s connection to Power BI consists of two parts:
|
||||
|
||||
- **Data Connector** fetches the data you uploaded from AEC apps to Speckle.
|
||||
- **3D Visual** allows you to see those models in 3D within Power BI.
|
||||
|
||||

|
||||
|
||||
# Repo Structure
|
||||
|
||||
This repo is home to our Power BI connector. The Speckle Server provides all the web-facing functionality and can be found [here](https://github.com/specklesystems/Server).
|
||||
|
||||
`src/powerbi-data-connector` contains all the code for the Data connector.
|
||||
|
||||
`src/powerbi-visual` contains all the code for 3D Visual.
|
||||
|
||||
# Installation
|
||||
|
||||
Speckle connector can be installed directly from [Manager for Speckle](https://speckle.systems/download/). Full instructions for [installation](https://speckle.guide/user/powerbi/installation.html) and [configuration](https://speckle.guide/user/powerbi/configuration.html) can be found on our docs.
|
||||
|
||||
# Using 3D Visual
|
||||
|
||||
3D Visual can be imported as any other Power BI custom visual.
|
||||
|
||||
1. Navigate to the Visualization Pane.
|
||||
2. Click the three dots (…) and select “Import a visual from a file”.
|
||||
3. Go to `Documents/Power BI Desktop/Custom Visuals` and import `Speckle 3D Visual.pbiviz` file.
|
||||
4. Speckle cube will appear in the Visualization pane.
|
||||
|
||||
For more on how to use the visual, [check our docs](https://speckle.guide/user/powerbi-visual/introduction.html).
|
||||
|
||||
# Usage
|
||||
|
||||
To get started with Power BI connectors, please take a look at the [documentation](https://speckle.guide/user/powerbi/introduction.html) and extensive [tutorials](https://www.youtube.com/playlist?list=PLlI5Dyt2HaEsZHG2WJ75WIM0Brx6VHT2S) published.
|
||||
|
||||
# **Developing & Debugging**
|
||||
|
||||
We encourage everyone interested to debug/hack/contribute/give feedback to this project.
|
||||
|
||||
## **Setup**
|
||||
|
||||
### **Install PowerQuery SDK**
|
||||
|
||||
Follow the instructions from the [official docs](https://docs.microsoft.com/en-us/power-query/installingsdk)
|
||||
|
||||
### **Build with Visual Studio**
|
||||
|
||||
Every time you build the connector, VisualStudio will copy the latest `.mez` connector file to the appropriate location. Just restart PowerBI to see the latest changes.
|
||||
|
||||
### **Debug**
|
||||
|
||||
You can start the PowerQuery connector in VisualStudio, this will open a standalone connector you can use for testing purposes.
|
||||
|
||||
We don't know of a way to debug the connector live in PowerBI, but we'd be happy to hear about it.
|
||||
|
||||
|
||||
# About Speckle
|
||||
|
||||
@@ -31,8 +91,7 @@ What is Speckle? Check our ](https://speckle.xyz) ⇒ creating an account at our public server
|
||||
- [](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
|
||||
- [](https://app.speckle.systems) ⇒ creating an account at our public server
|
||||
|
||||
### Resources
|
||||
|
||||
@@ -41,56 +100,3 @@ Give Speckle a try in no time by:
|
||||
- [](https://speckle.guide/dev/) reference on almost any end-user and developer functionality
|
||||
|
||||

|
||||
|
||||
# Repo structure
|
||||
|
||||
This repo is the home to our Speckle 2.0 PowerBI project. The [Speckle Server](https://github.com/specklesystems/Server) is providing all the web-facing functionality and can be found [here](https://github.com/specklesystems/Server).
|
||||
|
||||
## Install
|
||||
|
||||
Go to the [Releases](https://github.com/specklesystems/speckle-powerbi/releases) page, downlad the `.mez` file of the latest release and copy it into the following folder in your computer:
|
||||
|
||||
```
|
||||
YOUR_USER_FOLDER\Documents\Power BI Desktop\Custom Connectors\
|
||||
```
|
||||
If the folder doesn't exist, create it.
|
||||
|
||||
### Allow custom extensions to run
|
||||
|
||||
Go to `Settings -> Security -> Data Extensions` and activate the following option:
|
||||
|
||||

|
||||
|
||||
### Checking the connector is loaded
|
||||
|
||||
Now open PowerBI and you should see `Speckle (beta)` appear in the data source.
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
> More detailed instructions on how to use the connector will be added shortly!
|
||||
|
||||
### Current limitations
|
||||
|
||||
Chunked data currently is not automatically de-chunked when received, we are aware of this limitation and are working to resolve it!
|
||||
|
||||
## Developing & Debugging
|
||||
|
||||
We encourage everyone interested to debug / hack / contribute / give feedback to this project.
|
||||
|
||||
### Setup
|
||||
|
||||
#### Install PowerQuery SDK
|
||||
|
||||
Follow the instructions from the [official docs](https://docs.microsoft.com/en-us/power-query/installingsdk)
|
||||
|
||||
#### Build with Visual Studio
|
||||
|
||||
Every time you build the connector, VisualStudio will copy the latest `.mez` connector file to the appropriate location. Just restart PowerBI to see the latest changes.
|
||||
|
||||
#### Debug
|
||||
|
||||
You can start the PowerQuery connector in VisualStudio, this will open a standalone connector you can use for testing purposes.
|
||||
|
||||
We don't know of a way to debug the connector live in PowerBI, but we'd be happy to hear about it.
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "speckle-powerbi",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -1,34 +1,42 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"powerquery.general.mode": "SDK",
|
||||
"powerquery.sdk.defaultQueryFile": "${workspaceFolder}\\src\\powerbi-data-connector\\Speckle.query.pq",
|
||||
"powerquery.sdk.defaultExtension": "${workspaceFolder}\\bin\\${workspaceFolderBasename}.mez",
|
||||
"powerquery.sdk.defaultExtension": "${workspaceFolder}\\src\\powerbi-data-connector\\bin\\Speckle.mez",
|
||||
"files.eol": "\n",
|
||||
"files.watcherExclude": {
|
||||
"**/.git/objects/**": true,
|
||||
"**/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"
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
@@ -1,58 +1,36 @@
|
||||
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"),
|
||||
|
||||
// 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
|
||||
],
|
||||
|
||||
// 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
|
||||
@@ -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,39 @@
|
||||
// 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
|
||||
modelAndVersion = if List.Count(pathSegments) >= 4 and pathSegments{2} = "models"
|
||||
then Text.Split(pathSegments{3}, "@") else {},
|
||||
|
||||
// separate model ID from version ID
|
||||
modelId = if List.Count(modelAndVersion) > 0
|
||||
then modelAndVersion{0} else null,
|
||||
|
||||
// get version ID if it exists
|
||||
versionId = if List.Count(modelAndVersion) > 1
|
||||
then modelAndVersion{1} else null,
|
||||
|
||||
// validate URL structure
|
||||
isValid = projectId <> null and modelId <> null
|
||||
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'"
|
||||
]
|
||||
else
|
||||
[
|
||||
baseUrl = baseUrl,
|
||||
projectId = projectId,
|
||||
modelId = modelId,
|
||||
versionId = versionId
|
||||
]
|
||||
@@ -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 = model[name],
|
||||
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.dev") 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.dev", "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.dev/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.dev/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
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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,14 +213,7 @@
|
||||
{
|
||||
"essential": true,
|
||||
"name": "WebAccess",
|
||||
"parameters": [
|
||||
"https://speckle.xyz",
|
||||
"https://*.speckle.xyz",
|
||||
"https://latest.speckle.dev",
|
||||
"https://*.speckle.dev",
|
||||
"https://analytics.speckle.systems",
|
||||
"*"
|
||||
]
|
||||
"parameters": ["https://analytics.speckle.systems", "http://localhost:29364", "*"]
|
||||
},
|
||||
{
|
||||
"essential": false,
|
||||
|
||||
Generated
+5629
-3718
File diff suppressed because it is too large
Load Diff
@@ -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.18.14",
|
||||
"@speckle/objectloader": "^2.23.8",
|
||||
"@speckle/tailwind-theme": "2.23.2",
|
||||
"@speckle/ui-components": "2.23.2",
|
||||
"@speckle/viewer": "2.23.8",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,77 @@
|
||||
<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 ViewModeControls from 'src/components/ViewModeControls.vue'
|
||||
import { CanonicalView, SpeckleView } from '@speckle/viewer'
|
||||
import { CommonLoadingBar } from '@speckle/ui-components'
|
||||
import ViewerHandler from 'src/handlers/viewerHandler'
|
||||
import { useClickDragged } from 'src/composables/useClickDragged'
|
||||
import { isMultiSelect } from 'src/utils/isMultiSelect'
|
||||
import {
|
||||
selectionHandlerKey,
|
||||
storeKey,
|
||||
tooltipHandlerKey,
|
||||
viewerHandlerKey
|
||||
} from 'src/injectionKeys'
|
||||
import { SpeckleDataInput } from 'src/types'
|
||||
import { debounce, throttle } from 'lodash'
|
||||
import { ContextOption } from 'src/settings/colorSettings'
|
||||
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
|
||||
|
||||
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 +90,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,19 @@ import {
|
||||
LegacyViewer,
|
||||
IntersectionQuery,
|
||||
DefaultViewerParams,
|
||||
Box3,
|
||||
SpeckleView,
|
||||
CameraController
|
||||
CameraController,
|
||||
CameraEvent,
|
||||
SpeckleOfflineLoader
|
||||
} from '@speckle/viewer'
|
||||
import { pickViewableHit, projectToScreen } from '../utils/viewerUtils'
|
||||
import _ from 'lodash'
|
||||
import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
|
||||
import { PerspectiveCamera, OrthographicCamera, Box3 } from 'three'
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export default class ViewerHandler {
|
||||
private viewer: LegacyViewer
|
||||
private readonly parent: HTMLElement
|
||||
@@ -38,10 +44,9 @@ export default class ViewerHandler {
|
||||
break
|
||||
}
|
||||
|
||||
this.viewer.getExtension(CameraController).controls.maxPolarAngle = settings.camera
|
||||
.allowCameraUnder.value
|
||||
? Math.PI
|
||||
: Math.PI / 2
|
||||
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()
|
||||
@@ -59,10 +64,10 @@ export default class ViewerHandler {
|
||||
if (this.currentSectionBox === null) {
|
||||
const bbox = this.viewer.getSectionBoxFromObjects(objectIds)
|
||||
this.viewer.setSectionBox(bbox)
|
||||
this.currentSectionBox = bbox
|
||||
this.currentSectionBox = bbox as unknown as Box3
|
||||
} else {
|
||||
const bbox = this.viewer.getCurrentSectionBox()
|
||||
if (bbox) this.currentSectionBox = bbox
|
||||
if (bbox) this.currentSectionBox = bbox as unknown as Box3
|
||||
}
|
||||
this.viewer.sectionBoxOn()
|
||||
} else {
|
||||
@@ -72,7 +77,7 @@ export default class ViewerHandler {
|
||||
}
|
||||
|
||||
public addCameraUpdateEventListener(listener: (ev) => void) {
|
||||
this.viewer.getExtension(CameraController).controls.addEventListener('update', listener)
|
||||
this.viewer.getExtension(CameraController).on(CameraEvent.LateFrameUpdate, listener)
|
||||
}
|
||||
|
||||
public constructor(parent: HTMLElement) {
|
||||
@@ -86,6 +91,7 @@ export default class ViewerHandler {
|
||||
viewerSettings.verbose = false
|
||||
const viewer = new LegacyViewer(this.parent, viewerSettings)
|
||||
await viewer.init()
|
||||
console.log('Viewer initialized', viewer)
|
||||
this.viewer = viewer
|
||||
}
|
||||
|
||||
@@ -107,52 +113,27 @@ export default class ViewerHandler {
|
||||
}
|
||||
|
||||
public async loadObjectsWithAutoUnload(
|
||||
objectUrls: string[],
|
||||
objects: object[],
|
||||
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)
|
||||
// var objectsToUnload = _.difference([...this.loadedObjectsCache], rootObject)
|
||||
// await this.unloadObjects(objectsToUnload, signal)
|
||||
// await this.loadObjects(obj, onLoad, onError) // TODO: pass root object
|
||||
|
||||
await this.loadObjects(objects, onLoad, onError)
|
||||
}
|
||||
|
||||
public async loadObjects(
|
||||
objectUrls: string[],
|
||||
objects: object[],
|
||||
onLoad: (url: string, index: number) => void,
|
||||
onError: (url: string, error: Error) => void,
|
||||
signal: AbortSignal
|
||||
onError: (url: string, error: Error) => void
|
||||
) {
|
||||
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}`)
|
||||
}
|
||||
const stringifiedObject = JSON.stringify(objects)
|
||||
const loader = new SpeckleOfflineLoader(this.viewer.getWorldTree(), stringifiedObject)
|
||||
void this.viewer.unloadAll()
|
||||
void this.viewer.loadObject(loader, true)
|
||||
}
|
||||
|
||||
public async intersect(coords: { x: number; y: number }) {
|
||||
@@ -213,13 +194,15 @@ export default class ViewerHandler {
|
||||
|
||||
public getScreenPosition(worldPosition): { x: number; y: number } {
|
||||
return projectToScreen(
|
||||
this.viewer.getExtension(CameraController).controls.camera,
|
||||
this.viewer.getExtension(CameraController).renderingCamera as unknown as
|
||||
| PerspectiveCamera
|
||||
| OrthographicCamera,
|
||||
worldPosition
|
||||
)
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.viewer.getExtension(CameraController).controls.removeAllEventListeners()
|
||||
this.viewer.getExtension(CameraController).dispose()
|
||||
this.viewer.dispose()
|
||||
this.viewer = null
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export const pinia = createPinia()
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
LegacyViewer,
|
||||
DefaultViewerParams,
|
||||
FilteringState,
|
||||
IntersectionQuery,
|
||||
CameraController,
|
||||
CanonicalView,
|
||||
ViewModes,
|
||||
CameraEvent,
|
||||
SpeckleView
|
||||
} 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 declare enum ViewMode {
|
||||
DEFAULT = 0,
|
||||
DEFAULT_EDGES = 1,
|
||||
SHADED = 2,
|
||||
PEN = 3,
|
||||
ARCTIC = 4,
|
||||
COLORS = 5
|
||||
}
|
||||
|
||||
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
|
||||
forceViewerUpdate: () => void
|
||||
unIsolateObjects: () => void
|
||||
zoomExtends: () => void
|
||||
loadObjects: (objects: object[]) => void
|
||||
}
|
||||
|
||||
export class ViewerHandler {
|
||||
public emitter: Emitter
|
||||
public viewer: LegacyViewer
|
||||
private _needsRender = false
|
||||
private parent: HTMLElement
|
||||
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.parent = parent
|
||||
this.viewer.speckleRenderer.speckleCamera.on(
|
||||
CameraEvent.FrameUpdate,
|
||||
(needsUpdate: boolean) => {
|
||||
this.needsRender = needsUpdate
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
get needsRender(): boolean {
|
||||
return this._needsRender
|
||||
}
|
||||
|
||||
set needsRender(value: boolean) {
|
||||
if (this._needsRender !== value) {
|
||||
this._needsRender = value
|
||||
this.onNeedsRenderChanged(value)
|
||||
}
|
||||
}
|
||||
|
||||
private onNeedsRenderChanged(value: boolean) {
|
||||
// whenever the render is settled means that user stopped interaction, so we will set the camera position
|
||||
if (!value) {
|
||||
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[]) => {
|
||||
this.viewer.zoom(objectIds)
|
||||
}
|
||||
|
||||
public zoomExtends = () => this.viewer.zoom()
|
||||
|
||||
public setView = (view: CanonicalView) => this.viewer.setView(view)
|
||||
|
||||
public setSectionBox = (bboxActive: boolean, objectIds: string[]) => {
|
||||
// TODO
|
||||
return
|
||||
}
|
||||
|
||||
public setViewMode(viewMode: ViewMode) {
|
||||
const viewModes = this.viewer.getExtension(ViewModes)
|
||||
viewModes.setViewMode(viewMode)
|
||||
}
|
||||
|
||||
public selectObjects = async (objectIds: string[]) => {
|
||||
console.log('🔗 Handling setSelection inside ViewerHandler:', objectIds)
|
||||
if (objectIds) {
|
||||
await this.viewer.selectObjects(objectIds)
|
||||
}
|
||||
}
|
||||
|
||||
public colorObjectsByGroup = async (
|
||||
colorByIds: {
|
||||
objectIds: string[]
|
||||
color: string
|
||||
}[]
|
||||
) => {
|
||||
this.filteringState = await this.viewer.setUserObjectColors(colorByIds ?? [])
|
||||
}
|
||||
|
||||
public isolateObjects = async (objectIds: string[], ghost: boolean) => {
|
||||
await this.unIsolateObjects()
|
||||
this.filteringState = await this.viewer.isolateObjects(objectIds, 'powerbi', true, ghost)
|
||||
}
|
||||
|
||||
public unIsolateObjects = async () => {
|
||||
if (this.filteringState && this.filteringState.isolatedObjects) {
|
||||
this.filteringState = await this.viewer.unIsolateObjects(
|
||||
this.filteringState.isolatedObjects,
|
||||
'powerbi',
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public 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)
|
||||
// console.log(res, 'pick objects')
|
||||
|
||||
if (!res) {
|
||||
this.viewer.selectObjects([])
|
||||
return
|
||||
}
|
||||
return {
|
||||
hit: this.pickViewableHit(res.objects),
|
||||
objects: res.objects
|
||||
}
|
||||
}
|
||||
|
||||
public loadObjects = async (objects: object[]) => {
|
||||
await this.viewer.unloadAll()
|
||||
// const stringifiedObject = JSON.stringify(objects)
|
||||
//@ts-ignore
|
||||
const loader = new SpeckleObjectsOfflineLoader(this.viewer.getWorldTree(), objects)
|
||||
const store = useVisualStore()
|
||||
|
||||
const speckleViews = objects.filter(
|
||||
//@ts-ignore
|
||||
(o) => o.speckle_type === 'Objects.BuiltElements.View:Objects.BuiltElements.View3D'
|
||||
) as SpeckleView[]
|
||||
|
||||
store.setSpeckleViews(speckleViews)
|
||||
if (store.defaultViewModeInFile) {
|
||||
this.setViewMode(Number(store.defaultViewModeInFile))
|
||||
}
|
||||
await this.viewer.loadObject(loader, true)
|
||||
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]
|
||||
)
|
||||
const cameraController = this.viewer.getExtension(CameraController)
|
||||
cameraController.setCameraView({ position, target }, true)
|
||||
}
|
||||
}
|
||||
|
||||
private handlePing = (message: string) => {
|
||||
console.log(message)
|
||||
}
|
||||
|
||||
private pickViewableHit(hits: Hit[]): Hit | null {
|
||||
// let hit = null
|
||||
// if (this.filteringState.isolatedObjects) {
|
||||
// // Find the first hit contained in the isolated objects
|
||||
// hit = hits.find((hit) => {
|
||||
// const hitId = hit.object.id as string
|
||||
// return this.filteringState.isolatedObjects.includes(hitId)
|
||||
// })
|
||||
// }
|
||||
const hit = hits.find((h) => this.filteringState.isolatedObjects.includes(h.guid))
|
||||
return hit
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.viewer.getExtension(CameraController).dispose()
|
||||
this.viewer.dispose()
|
||||
this.viewer = null
|
||||
}
|
||||
}
|
||||
|
||||
const createViewer = async (parent: HTMLElement): Promise<LegacyViewer> => {
|
||||
const viewerSettings = DefaultViewerParams
|
||||
viewerSettings.showStats = false
|
||||
viewerSettings.verbose = false
|
||||
const viewer = new LegacyViewer(parent, viewerSettings)
|
||||
await viewer.init()
|
||||
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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { CanonicalView, SpeckleView, ViewMode } from '@speckle/viewer'
|
||||
import { IViewerEvents } from '@src/plugins/viewer'
|
||||
import { SpeckleDataInput } from '@src/types'
|
||||
import { zipJSONChunks } 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
|
||||
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) {
|
||||
lastLoadedRootObjectId.value = (dataInput.value.objects[0] as SpeckleObject).id
|
||||
console.log(`🔄 Forcing viewer re-render for new root object id.`)
|
||||
await viewerEmit.value('loadObjects', dataInput.value.objects)
|
||||
clearLoadingProgress()
|
||||
viewerReloadNeeded.value = false
|
||||
isViewerObjectsLoaded.value = true
|
||||
writeObjectsToFile(dataInput.value.objects)
|
||||
}
|
||||
|
||||
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 = (objects: object[]) => {
|
||||
const compressedChunks = zipJSONChunks(objects, 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
|
||||
}
|
||||
})
|
||||
@@ -10,10 +10,11 @@ export interface IViewerTooltip {
|
||||
}
|
||||
|
||||
export interface SpeckleDataInput {
|
||||
objectsToLoad: string[]
|
||||
objects: 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,77 @@
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses JSON objects in chunks properly.
|
||||
*/
|
||||
export function zipJSONChunks(objects, chunkSize = 1000) {
|
||||
const chunks = chunkArray(objects, 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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -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,224 @@ 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 response = await fetch(`http://localhost:29364/user-info/${id}`)
|
||||
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(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 objects: 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
|
||||
objects = 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,
|
||||
objects,
|
||||
objectIds,
|
||||
selectedIds,
|
||||
colorByIds: colorByIds.length > 0 ? colorByIds : null,
|
||||
objectTooltipData,
|
||||
view: matrixView
|
||||
view: matrixView,
|
||||
isFromStore: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FilteringState } from '@speckle/viewer'
|
||||
import { OrthographicCamera, PerspectiveCamera } from 'three'
|
||||
|
||||
export function projectToScreen(cam, loc) {
|
||||
export function projectToScreen(cam: OrthographicCamera | PerspectiveCamera, loc) {
|
||||
cam.updateProjectionMatrix()
|
||||
const copy = loc.clone()
|
||||
copy.project(cam)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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 { store } from 'src/store'
|
||||
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'
|
||||
@@ -26,18 +25,24 @@ import {
|
||||
} from 'powerbi-visuals-utils-dataviewutils/lib/dataViewWildcard'
|
||||
import { ColorSelectorSettings } from 'src/settings/colorSettings'
|
||||
|
||||
import { pinia } from './plugins/pinia'
|
||||
import { FieldInputState, useVisualStore } from './store/visualStore'
|
||||
import { unzipJSONChunk, unzipJSONChunks, zipJSONChunks } 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 +52,133 @@ 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
|
||||
).split(',')
|
||||
const objectsFromFile = unzipJSONChunks(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 +186,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()
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"sourceMap": true,
|
||||
"outDir": "./.tmp/build/",
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"lib": ["es2020", "dom"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user