Compare commits

3 Commits

Author SHA1 Message Date
a7fe20bcee Remove .env.demo containing sensitive URL
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:02:20 +08:00
chiayin
140aaefde4 fix files 2023-04-18 15:57:25 +08:00
chiayin
3f3da44b5c Organized into a demo version 2023-04-18 15:37:55 +08:00
302 changed files with 11281 additions and 48264 deletions

15
.env
View File

@@ -1,15 +0,0 @@
# The Lucia project.
# Copyright 2023-2026 DSP, inc. All rights reserved.
# Authors:
# chiayin.kuo@dsp.im (chiayin), 2023/1/31
# imacat.yang@dsp.im (imacat), 2026/3/6
#
# Default environment variables.
# Override in .env.local for local development.
# Backend API URL for the dev server proxy.
VUE_APP_API_URL = ""
# Custom Vite cache directory (default: node_modules/.vite).
# Set to /tmp/vite-cache for systemd deployment with PrivateTmp.
VITE_CACHE_DIR = ""

20
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,20 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-prettier",
],
overrides: [
{
files: ["cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}"],
extends: ["plugin:cypress/recommended"],
},
],
parserOptions: {
ecmaVersion: "latest",
},
};

14
.gitignore vendored
View File

@@ -15,9 +15,10 @@ coverage
*.local *.local
/dist /dist
# Playwright # Cypress
/test-results/ cypress.env.json
/playwright-report/ /cypress/videos/
/cypress/screenshots/
# Editor directories and files # Editor directories and files
vscode vscode
@@ -31,16 +32,11 @@ vscode
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.claude
# local env files # local env files
.env.demo
.env.local .env.local
.env.*.local .env.*.local
# TypeDoc generated documentation
/docs
.scannerwork .scannerwork
sonar-project.properties sonar-project.properties
excludes

170
README.md
View File

@@ -1,175 +1,57 @@
# The Lucia Project Frontend # frontend
The frontend of the Lucia project, a process mining platform for This template should help get you started developing with Vue 3 in Vite.
analyzing, discovering, and comparing business process workflows.
Built with [Vue 3][vue], [Vite][vite], [Pinia][pinia], ## Recommended IDE Setup
and [Tailwind CSS][tailwind].
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Features ## Customize configuration
- **Files** -- Upload, browse, and manage event log files (CSV). See [Vite Configuration Reference](https://vitejs.dev/config/).
- **Discover** -- Visualize process maps ([Cytoscape.js][cytoscape]),
analyze performance metrics ([Chart.js][chartjs]), and run
conformance checking against user-defined rules.
- **Compare** -- Side-by-side comparison of process maps and
dashboards.
- **Account Management** -- User account CRUD with role-based
access control.
## Project Setup
## Tech Stack
| Category | Technologies |
|----------|-------------|
| Framework | Vue 3.5, Vue Router 5, Pinia 3 |
| Build | Vite 7, TypeScript 5, Tailwind CSS 4 |
| UI | PrimeVue 4, PrimeIcons, SweetAlert2 |
| Visualization | Cytoscape.js (process maps), Chart.js (charts) |
| HTTP | Axios with JWT refresh token handling |
| Testing | Vitest 4 (unit/component), [Playwright][playwright] (E2E), [MSW][msw] (API mocking) |
| Linting | ESLint, Prettier |
## Prerequisites
- [Node.js][nodejs] (v18 or later)
- npm
## Getting Started
### Install dependencies
```sh ```sh
npm ci npm install
``` ```
### Configure environment ### Compile and Hot-Reload for Development
Copy `.env` to `.env.local` and set the backend API URL:
```sh
cp .env .env.local
```
```sh
# .env.local
VUE_APP_API_URL = "http://localhost:8000"
```
The dev server proxies `/api` requests to this URL.
### Start development server
```sh ```sh
npm run dev npm run dev
``` ```
The app will be available at `http://localhost:58249`. ### Compile and Minify for Production
## Testing
### Run unit and component tests
```sh
npx vitest run
```
### Run E2E tests
Build with MSW enabled first, then run [Playwright][playwright]:
```sh
npm run build:e2e
npm run test:e2e
```
For interactive E2E development with the Playwright UI:
```sh
npm run build:e2e
npm run test:e2e:ui
```
## Documentation
Generate API documentation with [TypeDoc][typedoc]:
```sh
npm run docs
```
Output is in the `docs/` directory. Open `docs/index.html` in a
browser to view.
## Build for Production
```sh ```sh
npm run build npm run build
``` ```
Output is in the `dist/` directory. Preview locally with: ### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh ```sh
npm run preview npm run test:unit
``` ```
### Run End-to-End Tests with [Cypress](https://www.cypress.io/)
## Project Structure ```sh
npm run test:e2e:dev
```
src/
├── api/ # Axios client with JWT interceptors
├── assets/ # CSS (Tailwind), static assets
├── components/ # Reusable Vue components
│ ├── Discover/ # Map, Conformance, Performance
│ ├── Compare/ # Comparison sidebar
│ └── File/ # Upload modal
├── module/ # Business logic (alerts, charts, sorting)
├── router/ # Vue Router configuration
├── stores/ # Pinia stores (state management)
├── utils/ # Utility functions (emitter, escaping)
└── views/ # Page-level route components
├── Discover/ # Map, Performance, Conformance
├── Compare/ # Dashboard, MapCompare
├── Files/ # File browser
├── Upload/ # File upload
└── Login/ # Authentication
``` ```
This runs the end-to-end tests against the Vite development server.
It is much faster than the production build.
## Copyright But it's still recommended to test the production build with `test:e2e` before deploying (e.g. in CI environments):
Copyright 2022-2026 DSP, inc. All rights reserved. ```sh
npm run build
npm run test:e2e
```
This software is proprietary. You may obtain, use, copy, edit or ### Lint with [ESLint](https://eslint.org/)
update this software with written agreements from DSP, inc.
```sh
## Authors npm run lint
```
- Chia-Yin Kuo (chiayin.kuo@dsp.im)
- imacat (imacat.yang@dsp.im)
## Acknowledgments
Code quality improvements assisted by [Claude Code][claude-code].
[vue]: https://vuejs.org/
[vite]: https://vitejs.dev/
[pinia]: https://pinia.vuejs.org/
[tailwind]: https://tailwindcss.com/
[cytoscape]: https://js.cytoscape.org/
[chartjs]: https://www.chartjs.org/
[nodejs]: https://nodejs.org/
[playwright]: https://playwright.dev/
[msw]: https://mswjs.io/
[typedoc]: https://typedoc.org/
[claude-code]: https://claude.ai/claude-code

8
cypress.config.js Normal file
View File

@@ -0,0 +1,8 @@
/* eslint-env node */
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
specPattern: "cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}",
},
});

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
},
"include": ["./**/*", "../support/**/*"]
}

View File

@@ -0,0 +1,44 @@
// 之後要優化: 每一個測試步驟要分開寫,不寫在同一個 it 裡面、將測試寫成 report 輸出成 html(嘗試)、功能模組化
const baseUrl = Cypress.env('baseUrl');
describe("Login to Logout", () => {
before(() => {
cy.visit(baseUrl); // 測試可否進入網站
cy.contains("h2", "LOGIN"); // 是否轉址到 /login 並顯示畫面
cy.url().should('include', 'login') // url path 要有 'Login',確定進入 login page
})
it("test login success and error", () => {
// 驗證帳密是否刪除前後空白、錯誤帳密是否顯示驗證、Button display
cy.fixture('users/id-not-exists').then(({username, password}) => {
cy.get('.btn-lg').should('be.disabled');
cy.get('#account').should('have.focus').type(username);
cy.get('.btn-lg').should('be.disabled');
cy.get('#password').type(password);
cy.get('.btn-lg').click();
cy.get('#account').should('have.value', 'test');
cy.get('#password').should('have.value', 'test');
cy.get('form').submit();
cy.contains("p", "Incorrect account or password.");
cy.url().should('include', 'login');
});
// 正確帳密登入
cy.get('#account').clear().type(` ${Cypress.env('user').username} `);
cy.get('#password').clear().type(` ${Cypress.env('user').password} `);
cy.get('.btn-lg').click();
cy.get('#account').should('have.value', Cypress.env('user').username);
cy.get('#password').should('have.value', Cypress.env('user').password);
cy.get('form').submit(); // 選取 form 表單並發送
// 轉址到 files 頁
cy.url().should('include', 'files');
cy.get('a #iconMember').scrollIntoView().click();
// 轉址到會員頁
cy.url().should('include', 'member-area');
// 登出
cy.get('.btn-sm').contains('log out').click();
});
});

View File

@@ -0,0 +1,4 @@
{
"username": " test ",
"password": " test "
}

View File

@@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

24
cypress/support/e2e.js Normal file
View File

@@ -0,0 +1,24 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands";
Cypress.on('uncaught:exception', (err, runnable) => {
// returning false here prevents Cypress from failing the test
return false
})
// Alternatively you can use CommonJS syntax:
// require('./commands')

28
doc/structure.txt Normal file
View File

@@ -0,0 +1,28 @@
hello-vite/
├── index.html Vite的進入點
├── node_modules
├── package.json
├── package-lock.json
├── public 不被 JavaScript 引用的靜態資源
│ └── favicon.ico 網頁標題欄 icon
├── README.md
├── src
│ ├── App.vue 網頁根元件
│ ├── main.js 程式進入點
│ ├── assets 靜態資源 EX: 圖片, CSS
│ │ ├── base.css
│ │ ├── logo.svg
│ │ └── main.css
│ ├── components 元件檔
│ │ ├── HelloWorld.vue
│ │ ├── icons (略 - 放所有 icon 的 svg 檔)
│ │ ├── TheWelcome.vue
│ │ └── WelcomeItem.vue
│ ├── router Vue Router 路由管理
│ │ └── index.js
│ ├── stores Pinia 狀態管理器
│ │ └── counter.js
│ └── views 路由元件
│ ├── AboutView.vue
│ └── HomeView.vue
└── vite.config.js vite 設定檔

View File

@@ -1,129 +0,0 @@
// The Lucia project.
// Copyright 2026-2026 DSP, inc. All rights reserved.
// Authors:
// imacat.yang@dsp.im (imacat), 2026/3/6
/**
* @module eslint.config
* ESLint flat configuration for Vue, Cypress, and
* Prettier integration.
*/
import js from "@eslint/js";
import pluginVue from "eslint-plugin-vue";
import pluginCypress from "eslint-plugin-cypress";
import skipFormatting from "@vue/eslint-config-prettier";
/** Browser runtime globals used across app and jsdom tests. */
const browserGlobals = {
window: "readonly",
document: "readonly",
navigator: "readonly",
location: "readonly",
localStorage: "readonly",
sessionStorage: "readonly",
console: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
FormData: "readonly",
Blob: "readonly",
URL: "readonly",
atob: "readonly",
btoa: "readonly",
};
/** Node.js globals used in config files. */
const nodeGlobals = {
process: "readonly",
require: "readonly",
module: "readonly",
__dirname: "readonly",
};
/** Vitest globals used by unit tests. */
const vitestGlobals = {
describe: "readonly",
it: "readonly",
test: "readonly",
expect: "readonly",
beforeEach: "readonly",
afterEach: "readonly",
beforeAll: "readonly",
afterAll: "readonly",
vi: "readonly",
};
export default [
{
ignores: [
"node_modules/**",
"dist/**",
"coverage/**",
"cypress/videos/**",
"cypress/screenshots/**",
"excludes/**",
"**/*.ts",
"**/*.d.ts",
],
},
{
files: ["**/*.{js,mjs,cjs,vue}"],
...js.configs.recommended,
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...browserGlobals,
},
},
rules: {
"vue/multi-word-component-names": "error",
},
},
...pluginVue.configs["flat/essential"],
skipFormatting,
{
files: ["tests/**/*.{js,mjs,cjs}"],
languageOptions: {
globals: {
...browserGlobals,
...nodeGlobals,
...vitestGlobals,
},
},
},
{
files: ["cypress/**/*.{js,mjs,cjs}"],
...pluginCypress.configs.recommended,
languageOptions: {
globals: {
...browserGlobals,
...nodeGlobals,
cy: "readonly",
Cypress: "readonly",
},
},
},
{
files: ["*.{js,mjs,cjs}", "**/*.config.{js,mjs,cjs}"],
languageOptions: {
globals: {
...nodeGlobals,
},
},
},
{
files: ["src/**/*.vue", "src/views/**/*.vue", "src/components/**/*.vue"],
rules: {
"vue/multi-word-component-names": "error",
"vue/no-side-effects-in-computed-properties": "error",
"vue/return-in-computed-property": "error",
"vue/no-parsing-error": "error",
"vue/valid-v-else": "error",
"vue/no-deprecated-v-on-native-modifier": "error",
"vue/require-valid-default-prop": "error",
"vue/no-unused-vars": "error",
},
},
];

View File

@@ -1,10 +1,3 @@
<!-- The Lucia project.
Copyright 2023-2026 DSP, inc. All rights reserved.
Authors:
chiayin.kuo@dsp.im (chiayin), 2023/1/31
cindy.chang@dsp.im (Cindy Chang), 2024/7/9
Application entry point HTML template. -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -15,6 +8,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

16058
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +1,57 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.0.0", "version": "0.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:e2e": "VITE_MSW=true vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"test:unit": "vitest run", "test:unit": "vitest --environment jsdom --root src/",
"test:e2e": "playwright test --config tests/e2e/playwright.config.ts", "test:e2e": "start-server-and-test preview :4173 'cypress run --e2e'",
"test:e2e:ui": "playwright test --config tests/e2e/playwright.config.ts --ui", "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
"lint:fix": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix",
"docs": "typedoc"
}, },
"dependencies": { "dependencies": {
"@primevue/themes": "^4.5.4", "autoprefixer": "^10.4.13",
"@tailwindcss/postcss": "^4.2.1", "axios": "^1.2.2",
"axios": "^1.13.6", "cytoscape": "^3.23.0",
"chart.js": "^4.5.1",
"chartjs-adapter-moment": "^1.0.1",
"chartjs-plugin-datalabels": "^2.2.0",
"cytoscape": "^3.33.1",
"cytoscape-cola": "^2.5.1",
"cytoscape-dagre": "^2.5.0", "cytoscape-dagre": "^2.5.0",
"cytoscape-fcose": "^2.2.0", "cytoscape-popper": "^2.0.0",
"cytoscape-popper": "^4.0.1", "javascript-color-gradient": "^2.4.4",
"decimal.js": "^10.6.0", "mitt": "^3.0.0",
"i18next": "^25.8.14", "moment": "^2.29.4",
"i18next-browser-languagedetector": "^8.2.1", "pinia": "^2.0.28",
"javascript-color-gradient": "^2.5.0", "postcss": "^8.4.20",
"lodash-es": "^4.17.23", "primeicons": "^6.0.1",
"mitt": "^3.0.1", "primevue": "^3.23.0",
"moment": "^2.30.1", "tailwindcss": "^3.2.4",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"primeicons": "^7.0.0",
"primevue": "^4.5.4",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"vue": "^3.5.29", "vue": "^3.2.45",
"vue-chartjs": "^5.3.3", "vue-axios": "^3.5.2",
"vue-router": "^5.0.3", "vue-router": "^4.1.6",
"vue-sweetalert2": "^5.0.11", "vue-toast-notification": "^3.0.4",
"vue-toast-notification": "^3.1.3",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@rushstack/eslint-patch": "^1.1.4",
"@playwright/test": "^1.58.2", "@vitejs/plugin-vue": "^4.0.0",
"@types/cytoscape": "^3.21.9", "@vue/eslint-config-prettier": "^7.0.0",
"@types/cytoscape-dagre": "^2.3.4", "@vue/test-utils": "^2.2.6",
"@types/cytoscape-popper": "^2.0.4", "autoprefixer": "^10.4.13",
"@types/node": "^25.3.5", "cypress": "^12.0.2",
"@vitejs/plugin-vue": "^6.0.4", "eslint": "^8.22.0",
"@vue/eslint-config-prettier": "^10.2.0", "eslint-plugin-cypress": "^2.12.1",
"@vue/test-utils": "^2.4.6", "eslint-plugin-vue": "^9.3.0",
"ajv": "^8.18.0", "html-webpack-plugin": "^5.5.0",
"ajv-formats": "^3.0.1", "jsdom": "^20.0.3",
"chartjs-plugin-dragdata": "^2.3.1", "postcss": "^8.4.20",
"eslint": "^10.0.2", "prettier": "^2.7.1",
"eslint-plugin-vue": "^10.8.0", "sass": "^1.57.1",
"jsdom": "^28.1.0", "start-server-and-test": "^1.15.2",
"msw": "^2.12.14", "tailwindcss": "^3.2.4",
"postcss": "^8.5.8", "vite": "^4.0.0",
"prettier": "^3.8.1", "vitest": "^0.25.6"
"sass": "^1.97.3",
"tailwindcss": "^4.2.1",
"ts-node": "^10.9.2",
"typedoc": "^0.28.17",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-eslint-parser": "^10.4.0"
} }
} }

View File

@@ -1,15 +1,6 @@
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// imacat.yang@dsp.im (imacat), 2026/3/6
/**
* @module postcss.config
* PostCSS configuration with Tailwind CSS plugin.
*/
module.exports = { module.exports = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, tailwindcss: {},
autoprefixer: {},
}, },
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,349 +0,0 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.12.14'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -2,15 +2,8 @@
<RouterView /> <RouterView />
</template> </template>
<script setup lang="ts"> <style scoped></style>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
/**
* @module App Root application component that renders the router view.
*/
import { RouterView } from "vue-router"; <script setup>
import { RouterLink, RouterView } from "vue-router";
</script> </script>

View File

@@ -1,53 +0,0 @@
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// imacat.yang@dsp.im (imacat), 2023/9/23
/** @module auth Authentication token refresh utilities. */
import axios from "axios";
import {
getCookie,
setCookie,
setCookieWithoutExpiration,
} from "@/utils/cookieUtil.js";
/**
* Refreshes the access token using the stored refresh token cookie.
*
* Uses plain axios (not apiClient) to avoid interceptor loops. Updates
* both the access token (session cookie) and refresh token (6-month
* expiry) cookies.
*
* @returns {Promise<string>} The new access token.
* @throws {Error} If the refresh request fails.
*/
export async function refreshTokenAndGetNew() {
const api = "/api/oauth/token";
const config = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
};
const data = {
grant_type: "refresh_token",
refresh_token: getCookie("luciaRefreshToken"),
};
const response = await axios.post(api, data, config);
const newAccessToken = response.data?.access_token;
const newRefreshToken = response.data?.refresh_token;
if (!newAccessToken || !newRefreshToken) {
throw new Error("Invalid token response structure");
}
setCookieWithoutExpiration("luciaToken", newAccessToken);
// Expire in ~6 months
const expiredMs = new Date();
expiredMs.setMonth(expiredMs.getMonth() + 6);
const days = Math.ceil(
(expiredMs.getTime() - Date.now()) / (24 * 60 * 60 * 1000),
);
setCookie("luciaRefreshToken", newRefreshToken, days);
return newAccessToken;
}

View File

@@ -1,99 +0,0 @@
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module apiClient Centralized axios instance with request/response
* interceptors for authentication token management and automatic
* 401 token refresh with request queuing.
*/
import axios from "axios";
import { getCookie, deleteCookie } from "@/utils/cookieUtil.js";
/** Axios instance configured with auth interceptors. */
const apiClient = axios.create();
// Request interceptor: automatically attach Authorization header
apiClient.interceptors.request.use((config) => {
const token = getCookie("luciaToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor: handle 401 by attempting token refresh
let isRefreshing = false;
let pendingRequests = [];
/**
* Resolves all pending requests with the new access token.
* @param {string} newToken - The refreshed access token.
*/
function onRefreshSuccess(newToken) {
pendingRequests.forEach((cb) => cb(newToken));
pendingRequests = [];
}
/**
* Rejects all pending requests with the refresh error.
* @param {Error} error - The token refresh error.
*/
function onRefreshFailure(error) {
pendingRequests.forEach((cb) => cb(null, error));
pendingRequests = [];
}
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Only attempt refresh on 401, and not for auth endpoints or already-retried requests
if (
error.response?.status !== 401 ||
originalRequest._retried ||
originalRequest.url === "/api/oauth/token"
) {
throw error;
}
if (isRefreshing) {
// Queue this request until the refresh completes
return new Promise((resolve, reject) => {
pendingRequests.push((newToken, err) => {
if (err) return reject(err);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
originalRequest._retried = true;
resolve(apiClient(originalRequest));
});
});
}
isRefreshing = true;
originalRequest._retried = true;
try {
// Dynamic import to avoid circular dependency with login store
const { refreshTokenAndGetNew } = await import("@/api/auth.js");
const newToken = await refreshTokenAndGetNew();
isRefreshing = false;
onRefreshSuccess(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
isRefreshing = false;
onRefreshFailure(refreshError);
// Refresh failed: clear auth and redirect to login
deleteCookie("luciaToken");
deleteCookie("luciaRefreshToken");
deleteCookie("isLuciaLoggedIn");
globalThis.location.href = "/login";
throw refreshError;
}
},
);
export default apiClient;

View File

@@ -1,13 +1,4 @@
/* The Lucia project. /* 全域字型 */
Copyright 2023-2026 DSP, inc. All rights reserved.
Authors:
chiayin.kuo@dsp.im (chiayin), 2023/4/18
cindy.chang@dsp.im (Cindy Chang), 2024/8/16
Base CSS layer with global font, validation, height,
and PrimeVue sidebar overrides. */
/* Global font */
@layer base { @layer base {
html { html {
font-family: 'Roboto', sans-serif, system-ui; font-family: 'Roboto', sans-serif, system-ui;
@@ -27,13 +18,3 @@
.h-screen-main { .h-screen-main {
height: calc(100vh - 104px); height: calc(100vh - 104px);
} }
/* button */
.disable-hover {
@apply pointer-events-none
}
/* Map i panel ; overwrite primevue style */
.p-sidebar .p-sidebar-header {
padding: 8px;
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,6 +0,0 @@
<svg width="216" height="48" viewBox="0 0 216 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" fill="white"/>
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" stroke="#0099FF"/>
<rect x="4" y="4" width="40" height="40" rx="20" fill="white"/>
<rect x="19" y="19" width="10" height="10" rx="5" fill="#BFE5FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 382 B

View File

@@ -1,6 +0,0 @@
<svg width="216" height="48" viewBox="0 0 216 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" fill="white"/>
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" stroke="#64748B"/>
<rect x="4" y="4" width="40" height="40" rx="20" fill="white"/>
<rect x="19" y="19" width="10" height="10" rx="5" fill="#BFE5FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 382 B

View File

@@ -1,7 +0,0 @@
<svg width="216" height="48" viewBox="0 0 216 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" fill="white"/>
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" stroke="#0099FF"/>
<rect x="4" y="4" width="40" height="40" rx="20" fill="white"/>
<rect x="14" y="14" width="20" height="20" rx="10" fill="#BFE5FF"/>
<rect x="19" y="19" width="10" height="10" rx="5" fill="#80CCFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 450 B

View File

@@ -1,7 +0,0 @@
<svg width="216" height="48" viewBox="0 0 216 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" fill="white"/>
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" stroke="#64748B"/>
<rect x="4" y="4" width="40" height="40" rx="20" fill="white"/>
<rect x="14" y="14" width="20" height="20" rx="10" fill="#BFE5FF"/>
<rect x="19" y="19" width="10" height="10" rx="5" fill="#80CCFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 450 B

View File

@@ -1,8 +0,0 @@
<svg width="216" height="48" viewBox="0 0 216 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" fill="white"/>
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" stroke="#0099FF"/>
<rect x="4" y="4" width="40" height="40" rx="20" fill="white"/>
<rect x="9" y="9" width="30" height="30" rx="15" fill="#BFE5FF"/>
<rect x="14" y="14" width="20" height="20" rx="10" fill="#80CCFF"/>
<rect x="19" y="19" width="10" height="10" rx="5" fill="#0099FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 516 B

View File

@@ -1,8 +0,0 @@
<svg width="216" height="48" viewBox="0 0 216 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" fill="white"/>
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" stroke="#64748B"/>
<rect x="4" y="4" width="40" height="40" rx="20" fill="white"/>
<rect x="9" y="9" width="30" height="30" rx="15" fill="#BFE5FF"/>
<rect x="14" y="14" width="20" height="20" rx="10" fill="#80CCFF"/>
<rect x="19" y="19" width="10" height="10" rx="5" fill="#0099FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 516 B

View File

@@ -1,8 +0,0 @@
<svg width="216" height="48" viewBox="0 0 216 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" fill="white"/>
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" stroke="#0099FF"/>
<rect x="4" y="4" width="40" height="40" rx="20" fill="#BFE5FF"/>
<rect x="9" y="9" width="30" height="30" rx="15" fill="#80CCFF"/>
<rect x="14" y="14" width="20" height="20" rx="10" fill="#0099FF"/>
<rect x="19" y="19" width="10" height="10" rx="5" fill="#0073BF"/>
</svg>

Before

Width:  |  Height:  |  Size: 518 B

View File

@@ -1,8 +0,0 @@
<svg width="216" height="48" viewBox="0 0 216 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" fill="white"/>
<rect x="0.5" y="0.5" width="215" height="47" rx="23.5" stroke="#64748B"/>
<rect x="4" y="4" width="40" height="40" rx="20" fill="#BFE5FF"/>
<rect x="9" y="9" width="30" height="30" rx="15" fill="#80CCFF"/>
<rect x="14" y="14" width="20" height="20" rx="10" fill="#0099FF"/>
<rect x="19" y="19" width="10" height="10" rx="5" fill="#0073BF"/>
</svg>

Before

Width:  |  Height:  |  Size: 518 B

View File

@@ -1,11 +1,3 @@
/* The Lucia project.
Copyright 2023-2026 DSP, inc. All rights reserved.
Authors:
chiayin.kuo@dsp.im (chiayin), 2023/2/17
Reusable component styles including loaders, scrollbar,
buttons, and toggle buttons. */
/* loading */ /* loading */
.loader { .loader {
width: 64px; width: 64px;
@@ -38,29 +30,62 @@
transform: rotate(360deg) transform: rotate(360deg)
} }
} }
/* loaderBar */
/* <span class="loaderBar"></span> */
.loaderBar {
width: 80%;
height: 16px;
display: inline-block;
background-color: #0099FF;
border: 1px solid #0099FF;
border-radius: 4px;
background-image: linear-gradient(45deg, rgba(0, 0, 0, 0.25) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.25) 50%, rgba(0, 0, 0, 0.25) 75%, transparent 75%, transparent);
font-size: 30px;
background-size: 1em 1em;
box-sizing: border-box;
animation: barStripe 1s linear infinite;
}
@keyframes barStripe { /* toggle */
0% { /* <div class="toggle">
background-position: 0 0; <input type="checkbox"/>
} <label></label>
100% { </div> */
background-position: 1em 0; .toggle {
} position: relative;
}
.toggle input[type="checkbox"] {
position: absolute;
left: 0;
top: 0;
z-index: 10;
width: 100%;
height: 100%;
cursor: pointer;
opacity: 0;
}
.toggle label {
position: relative;
display: flex;
align-items: center;
}
.toggle label:before {
content: '';
border: 5px solid #bbb;
height: 35px;
width: 70px;
position: relative;
display: inline-block;
border-radius: 46px;
transition: 0.2s ease-in;
}
.toggle label:after {
content: '';
position: absolute;
background: #555;
width: 28px;
height: 28px;
left: 8px;
top: 8px;
border-radius: 50%;
z-index: 2;
box-shadow: 0 0 5px #0002;
transition: 0.2s ease-in;
}
.toggle input[type="checkbox"]:hover + label:after {
box-shadow: 0 2px 15px 0 #0002, 0 3px 8px 0 #0001;
}
.toggle input[type="checkbox"]:checked + label:before {
border-color: #77C2BB;
}
.toggle input[type="checkbox"]:checked + label:after {
background: #009688;
left: 44px;
} }
/* components */ /* components */
@@ -75,11 +100,11 @@
} }
.scrollbar::-webkit-scrollbar-thumb { .scrollbar::-webkit-scrollbar-thumb {
@apply bg-neutral-300 rounded-full @apply bg-primary rounded-full
} }
.scrollbar::-webkit-scrollbar-thumb:hover { .scrollbar::-webkit-scrollbar-thumb:hover {
@apply bg-neutral-400 @apply bg-primary
} }
} }
@@ -94,10 +119,7 @@
@apply px-4 py-2.5 @apply px-4 py-2.5
} }
.btn-c-primary { .btn-c-primary {
@apply hover:text-neutral-50 hover:bg-primary border border-primary bg-neutral-50 text-primary active:ring focus:outline-none active:ring-primary/50 focus:ring focus:ring-primary/50 @apply text-neutral-50 bg-primary border border-primary hover:bg-neutral-50 hover:text-primary hover:border hover:border-primary active:border active:ring focus:outline-none focus:border-primary focus:ring
}
.btn-cfm-secondary {
@apply border border-cfm-secondary bg-neutral-50 text-cfm-secondary hover:text-neutral-50 hover:bg-cfm-secondary active:ring focus:outline-none active:ring-cfm-secondary/50 focus:ring focus:ring-cfm-secondary/50
} }
.btn-disable { .btn-disable {
@apply border border-neutral-200 bg-neutral-50 text-neutral-200 @apply border border-neutral-200 bg-neutral-50 text-neutral-200

View File

@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.78415 2.03878C9.62269 1.72766 9.36953 1.46508 9.05382 1.28129C8.73812 1.09751 8.37272 1 7.99974 1C7.62675 1 7.26135 1.09751 6.94565 1.28129C6.62994 1.46508 6.37678 1.72766 6.21532 2.03878L0.520353 12.4048C-0.160994 13.6423 0.699988 15.2857 2.30407 15.2857H13.6947C15.2995 15.2857 16.1591 13.643 15.4791 12.4048L9.78415 2.03878ZM7.99974 5.54628C8.18584 5.54628 8.36432 5.61468 8.49591 5.73645C8.6275 5.85822 8.70143 6.02337 8.70143 6.19557V9.44205C8.70143 9.61425 8.6275 9.77941 8.49591 9.90117C8.36432 10.0229 8.18584 10.0913 7.99974 10.0913C7.81363 10.0913 7.63516 10.0229 7.50356 9.90117C7.37197 9.77941 7.29804 9.61425 7.29804 9.44205V6.19557C7.29804 6.02337 7.37197 5.85822 7.50356 5.73645C7.63516 5.61468 7.81363 5.54628 7.99974 5.54628ZM7.99974 11.0653C8.18584 11.0653 8.36432 11.1337 8.49591 11.2555C8.6275 11.3772 8.70143 11.5424 8.70143 11.7146V12.0392C8.70143 12.2114 8.6275 12.3766 8.49591 12.4984C8.36432 12.6201 8.18584 12.6885 7.99974 12.6885C7.81363 12.6885 7.63516 12.6201 7.50356 12.4984C7.37197 12.3766 7.29804 12.2114 7.29804 12.0392V11.7146C7.29804 11.5424 7.37197 11.3772 7.50356 11.2555C7.63516 11.1337 7.81363 11.0653 7.99974 11.0653Z" fill="#FF3366"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="16" height="16" rx="2" fill="white" stroke="#0099FF" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 200 B

View File

@@ -1,3 +0,0 @@
<svg width="14" height="11" viewBox="0 0 14 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 10.4999L0 5.53988L1.59 3.99988L5 7.34988L12.41 -0.00012207L14 1.57988L5 10.4999Z" fill="#0099FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 214 B

View File

@@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="16" height="16" rx="2" fill="white" stroke="#CBD5E1" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 200 B

View File

@@ -1,3 +0,0 @@
<svg width="22" height="17" viewBox="0 0 22 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.3782 3.89078C21.1637 3.7117 20.9032 3.59652 20.6263 3.5584C20.3495 3.52027 20.0675 3.56073 19.8126 3.67515L15.0688 5.78453L12.3126 0.815776C12.1809 0.583799 11.99 0.390881 11.7594 0.256672C11.5289 0.122463 11.2669 0.0517578 11.0001 0.0517578C10.7333 0.0517578 10.4713 0.122463 10.2408 0.256672C10.0102 0.390881 9.81934 0.583799 9.6876 0.815776L6.93135 5.78453L2.1876 3.67515C1.93214 3.5609 1.64981 3.52039 1.37252 3.5582C1.09524 3.59601 0.834067 3.71064 0.618523 3.88912C0.402979 4.0676 0.241662 4.30282 0.15281 4.56819C0.0639569 4.83356 0.0511114 5.11849 0.115725 5.39078L2.49697 15.5439C2.54251 15.7405 2.62747 15.9257 2.74672 16.0885C2.86597 16.2513 3.01702 16.3881 3.19072 16.4908C3.42589 16.6315 3.69477 16.7061 3.96885 16.7064C4.10208 16.7062 4.23462 16.6872 4.3626 16.6502C8.70306 15.4501 13.2878 15.4501 17.6282 16.6502C18.0245 16.7543 18.446 16.697 18.8001 16.4908C18.9749 16.3894 19.1267 16.253 19.2461 16.09C19.3655 15.927 19.4499 15.7411 19.4938 15.5439L21.8845 5.39078C21.9484 5.11841 21.9348 4.83361 21.8454 4.56855C21.7559 4.30349 21.5941 4.06872 21.3782 3.89078ZM18.0313 15.2064C13.4274 13.9314 8.56346 13.9314 3.95948 15.2064L1.57823 5.05328L6.32197 7.15328C6.66236 7.30802 7.04868 7.32831 7.4034 7.21007C7.75813 7.09182 8.05501 6.8438 8.23447 6.51578L11.0001 1.54703L13.7657 6.51578C13.9452 6.8438 14.2421 7.09182 14.5968 7.21007C14.9515 7.32831 15.3378 7.30802 15.6782 7.15328L20.422 5.05328L18.0313 15.2064ZM14.7501 12.2345C14.7294 12.4194 14.6417 12.5902 14.5034 12.7146C14.3652 12.839 14.1861 12.9084 14.0001 12.9095H13.9251C11.9801 12.7126 10.0201 12.7126 8.0751 12.9095C7.87745 12.9305 7.67955 12.8722 7.52486 12.7474C7.37017 12.6226 7.27135 12.4415 7.2501 12.2439C7.23172 12.0453 7.29222 11.8474 7.41852 11.6931C7.54482 11.5387 7.72679 11.4402 7.9251 11.4189C9.96943 11.2033 12.0308 11.2033 14.0751 11.4189C14.2716 11.4402 14.4521 11.5374 14.5782 11.6897C14.7042 11.842 14.7659 12.0375 14.7501 12.2345Z" fill="#0F172A"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.5559 5.07692H16.667V4.30769C16.6644 3.69643 16.4178 3.11093 15.9807 2.6787C15.5437 2.24647 14.9517 2.00253 14.3337 2H9.66699C9.04894 2.00253 8.45694 2.24647 8.0199 2.6787C7.58287 3.11093 7.33622 3.69643 7.33366 4.30769V5.07692H3.44477C3.23849 5.07692 3.04066 5.15797 2.8948 5.30223C2.74894 5.44648 2.66699 5.64214 2.66699 5.84615C2.66699 6.05017 2.74894 6.24582 2.8948 6.39008C3.04066 6.53434 3.23849 6.61538 3.44477 6.61538H4.22255V20.4615C4.22255 20.8696 4.38644 21.2609 4.67816 21.5494C4.96988 21.8379 5.36554 22 5.7781 22H18.2225C18.6351 22 19.0308 21.8379 19.3225 21.5494C19.6142 21.2609 19.7781 20.8696 19.7781 20.4615V6.61538H20.5559C20.7622 6.61538 20.96 6.53434 21.1059 6.39008C21.2517 6.24582 21.3337 6.05017 21.3337 5.84615C21.3337 5.64214 21.2517 5.44648 21.1059 5.30223C20.96 5.15797 20.7622 5.07692 20.5559 5.07692ZM8.88921 4.30769C8.88921 4.10368 8.97116 3.90802 9.11702 3.76376C9.26288 3.61951 9.46071 3.53846 9.66699 3.53846H14.3337C14.5399 3.53846 14.7378 3.61951 14.8836 3.76376C15.0295 3.90802 15.1114 4.10368 15.1114 4.30769V5.07692H8.88921V4.30769ZM18.2225 20.4615H5.7781V6.61538H18.2225V20.4615ZM10.4448 10.4615V16.6154C10.4448 16.8194 10.3628 17.0151 10.217 17.1593C10.0711 17.3036 9.87327 17.3846 9.66699 17.3846C9.46071 17.3846 9.26288 17.3036 9.11702 17.1593C8.97116 17.0151 8.88921 16.8194 8.88921 16.6154V10.4615C8.88921 10.2575 8.97116 10.0619 9.11702 9.91761C9.26288 9.77335 9.46071 9.69231 9.66699 9.69231C9.87327 9.69231 10.0711 9.77335 10.217 9.91761C10.3628 10.0619 10.4448 10.2575 10.4448 10.4615ZM15.1114 10.4615V16.6154C15.1114 16.8194 15.0295 17.0151 14.8836 17.1593C14.7378 17.3036 14.5399 17.3846 14.3337 17.3846C14.1274 17.3846 13.9295 17.3036 13.7837 17.1593C13.6378 17.0151 13.5559 16.8194 13.5559 16.6154V10.4615C13.5559 10.2575 13.6378 10.0619 13.7837 9.91761C13.9295 9.77335 14.1274 9.69231 14.3337 9.69231C14.5399 9.69231 14.7378 9.77335 14.8836 9.91761C15.0295 10.0619 15.1114 10.2575 15.1114 10.4615Z" fill="#64748B"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.5559 5.07692H16.667V4.30769C16.6644 3.69643 16.4178 3.11093 15.9807 2.6787C15.5437 2.24647 14.9517 2.00253 14.3337 2H9.66699C9.04894 2.00253 8.45694 2.24647 8.0199 2.6787C7.58287 3.11093 7.33622 3.69643 7.33366 4.30769V5.07692H3.44477C3.23849 5.07692 3.04066 5.15797 2.8948 5.30223C2.74894 5.44648 2.66699 5.64214 2.66699 5.84615C2.66699 6.05017 2.74894 6.24582 2.8948 6.39008C3.04066 6.53434 3.23849 6.61538 3.44477 6.61538H4.22255V20.4615C4.22255 20.8696 4.38644 21.2609 4.67816 21.5494C4.96988 21.8379 5.36554 22 5.7781 22H18.2225C18.6351 22 19.0308 21.8379 19.3225 21.5494C19.6142 21.2609 19.7781 20.8696 19.7781 20.4615V6.61538H20.5559C20.7622 6.61538 20.96 6.53434 21.1059 6.39008C21.2517 6.24582 21.3337 6.05017 21.3337 5.84615C21.3337 5.64214 21.2517 5.44648 21.1059 5.30223C20.96 5.15797 20.7622 5.07692 20.5559 5.07692ZM8.88921 4.30769C8.88921 4.10368 8.97116 3.90802 9.11702 3.76376C9.26288 3.61951 9.46071 3.53846 9.66699 3.53846H14.3337C14.5399 3.53846 14.7378 3.61951 14.8836 3.76376C15.0295 3.90802 15.1114 4.10368 15.1114 4.30769V5.07692H8.88921V4.30769ZM18.2225 20.4615H5.7781V6.61538H18.2225V20.4615ZM10.4448 10.4615V16.6154C10.4448 16.8194 10.3628 17.0151 10.217 17.1593C10.0711 17.3036 9.87327 17.3846 9.66699 17.3846C9.46071 17.3846 9.26288 17.3036 9.11702 17.1593C8.97116 17.0151 8.88921 16.8194 8.88921 16.6154V10.4615C8.88921 10.2575 8.97116 10.0619 9.11702 9.91761C9.26288 9.77335 9.46071 9.69231 9.66699 9.69231C9.87327 9.69231 10.0711 9.77335 10.217 9.91761C10.3628 10.0619 10.4448 10.2575 10.4448 10.4615ZM15.1114 10.4615V16.6154C15.1114 16.8194 15.0295 17.0151 14.8836 17.1593C14.7378 17.3036 14.5399 17.3846 14.3337 17.3846C14.1274 17.3846 13.9295 17.3036 13.7837 17.1593C13.6378 17.0151 13.5559 16.8194 13.5559 16.6154V10.4615C13.5559 10.2575 13.6378 10.0619 13.7837 9.91761C13.9295 9.77335 14.1274 9.69231 14.3337 9.69231C14.5399 9.69231 14.7378 9.77335 14.8836 9.91761C15.0295 10.0619 15.1114 10.2575 15.1114 10.4615Z" fill="#FF3366"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.75 10.5C18.75 10.6989 18.671 10.8897 18.5303 11.0303C18.3897 11.171 18.1989 11.25 18 11.25H14.25C14.0511 11.25 13.8603 11.171 13.7197 11.0303C13.579 10.8897 13.5 10.6989 13.5 10.5C13.5 10.3011 13.579 10.1103 13.7197 9.96967C13.8603 9.82902 14.0511 9.75 14.25 9.75H18C18.1989 9.75 18.3897 9.82902 18.5303 9.96967C18.671 10.1103 18.75 10.3011 18.75 10.5ZM18 12.75H14.25C14.0511 12.75 13.8603 12.829 13.7197 12.9697C13.579 13.1103 13.5 13.3011 13.5 13.5C13.5 13.6989 13.579 13.8897 13.7197 14.0303C13.8603 14.171 14.0511 14.25 14.25 14.25H18C18.1989 14.25 18.3897 14.171 18.5303 14.0303C18.671 13.8897 18.75 13.6989 18.75 13.5C18.75 13.3011 18.671 13.1103 18.5303 12.9697C18.3897 12.829 18.1989 12.75 18 12.75ZM12.2625 15.5625C12.2878 15.6573 12.2941 15.7562 12.2809 15.8535C12.2677 15.9507 12.2353 16.0444 12.1855 16.129C12.1358 16.2136 12.0698 16.2875 11.9912 16.3463C11.9127 16.4052 11.8233 16.4479 11.7281 16.4719C11.6327 16.4973 11.5332 16.5037 11.4353 16.4906C11.3374 16.4775 11.2431 16.4452 11.1578 16.3955C11.0724 16.3459 10.9977 16.2799 10.9379 16.2013C10.8781 16.1227 10.8344 16.033 10.8094 15.9375C10.6823 15.4576 10.4 15.0333 10.0067 14.7305C9.61326 14.4278 9.13079 14.2636 8.63437 14.2636C8.13796 14.2636 7.65549 14.4278 7.26209 14.7305C6.8687 15.0333 6.58648 15.4576 6.45938 15.9375C6.41615 16.0985 6.32112 16.2409 6.18896 16.3425C6.0568 16.4442 5.89486 16.4995 5.72812 16.5L5.54062 16.4719C5.44547 16.4479 5.35603 16.4052 5.2775 16.3463C5.19898 16.2875 5.13294 16.2136 5.08322 16.129C5.0335 16.0444 5.00109 15.9507 4.98788 15.8535C4.97466 15.7562 4.98091 15.6573 5.00625 15.5625C5.23456 14.676 5.77774 13.9028 6.53437 13.3875C6.10882 12.9704 5.81713 12.436 5.69649 11.8524C5.57586 11.2688 5.63175 10.6626 5.85703 10.1109C6.08232 9.55922 6.46679 9.08714 6.96143 8.75484C7.45608 8.42254 8.03848 8.24507 8.63437 8.24507C9.23027 8.24507 9.81267 8.42254 10.3073 8.75484C10.802 9.08714 11.1864 9.55922 11.4117 10.1109C11.637 10.6626 11.6929 11.2688 11.5723 11.8524C11.4516 12.436 11.1599 12.9704 10.7344 13.3875C11.491 13.9028 12.0342 14.676 12.2625 15.5625ZM8.63437 12.75C8.93105 12.75 9.22106 12.662 9.46773 12.4972C9.7144 12.3324 9.90666 12.0981 10.0202 11.824C10.1337 11.5499 10.1634 11.2483 10.1056 10.9574C10.0477 10.6664 9.90481 10.3991 9.69504 10.1893C9.48526 9.97956 9.21798 9.8367 8.92701 9.77882C8.63604 9.72094 8.33444 9.75065 8.06035 9.86418C7.78626 9.97771 7.55199 10.17 7.38717 10.4166C7.22235 10.6633 7.13438 10.9533 7.13437 11.25C7.13437 11.6478 7.29241 12.0294 7.57372 12.3107C7.85502 12.592 8.23655 12.75 8.63437 12.75ZM21.75 5.25V18.75C21.75 19.1478 21.592 19.5294 21.3107 19.8107C21.0294 20.092 20.6478 20.25 20.25 20.25H3.75C3.35218 20.25 2.97064 20.092 2.68934 19.8107C2.40804 19.5294 2.25 19.1478 2.25 18.75V5.25C2.25 4.85218 2.40804 4.47064 2.68934 4.18934C2.97064 3.90804 3.35218 3.75 3.75 3.75H20.25C20.6478 3.75 21.0294 3.90804 21.3107 4.18934C21.592 4.47064 21.75 4.85218 21.75 5.25ZM20.25 18.75V5.25H3.75V18.75H20.25Z" fill="#64748B"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.75 10.5C18.75 10.6989 18.671 10.8897 18.5303 11.0303C18.3897 11.171 18.1989 11.25 18 11.25H14.25C14.0511 11.25 13.8603 11.171 13.7197 11.0303C13.579 10.8897 13.5 10.6989 13.5 10.5C13.5 10.3011 13.579 10.1103 13.7197 9.96967C13.8603 9.82902 14.0511 9.75 14.25 9.75H18C18.1989 9.75 18.3897 9.82902 18.5303 9.96967C18.671 10.1103 18.75 10.3011 18.75 10.5ZM18 12.75H14.25C14.0511 12.75 13.8603 12.829 13.7197 12.9697C13.579 13.1103 13.5 13.3011 13.5 13.5C13.5 13.6989 13.579 13.8897 13.7197 14.0303C13.8603 14.171 14.0511 14.25 14.25 14.25H18C18.1989 14.25 18.3897 14.171 18.5303 14.0303C18.671 13.8897 18.75 13.6989 18.75 13.5C18.75 13.3011 18.671 13.1103 18.5303 12.9697C18.3897 12.829 18.1989 12.75 18 12.75ZM12.2625 15.5625C12.2878 15.6573 12.2941 15.7562 12.2809 15.8535C12.2677 15.9507 12.2353 16.0444 12.1855 16.129C12.1358 16.2136 12.0698 16.2875 11.9912 16.3463C11.9127 16.4052 11.8233 16.4479 11.7281 16.4719C11.6327 16.4973 11.5332 16.5037 11.4353 16.4906C11.3374 16.4775 11.2431 16.4452 11.1578 16.3955C11.0724 16.3459 10.9977 16.2799 10.9379 16.2013C10.8781 16.1227 10.8344 16.033 10.8094 15.9375C10.6823 15.4576 10.4 15.0333 10.0067 14.7305C9.61326 14.4278 9.13079 14.2636 8.63437 14.2636C8.13796 14.2636 7.65549 14.4278 7.26209 14.7305C6.8687 15.0333 6.58648 15.4576 6.45938 15.9375C6.41615 16.0985 6.32112 16.2409 6.18896 16.3425C6.0568 16.4442 5.89486 16.4995 5.72812 16.5L5.54062 16.4719C5.44547 16.4479 5.35603 16.4052 5.2775 16.3463C5.19898 16.2875 5.13294 16.2136 5.08322 16.129C5.0335 16.0444 5.00109 15.9507 4.98788 15.8535C4.97466 15.7562 4.98091 15.6573 5.00625 15.5625C5.23456 14.676 5.77774 13.9028 6.53437 13.3875C6.10882 12.9704 5.81713 12.436 5.69649 11.8524C5.57586 11.2688 5.63175 10.6626 5.85703 10.1109C6.08232 9.55922 6.46679 9.08714 6.96143 8.75484C7.45608 8.42254 8.03848 8.24507 8.63437 8.24507C9.23027 8.24507 9.81267 8.42254 10.3073 8.75484C10.802 9.08714 11.1864 9.55922 11.4117 10.1109C11.637 10.6626 11.6929 11.2688 11.5723 11.8524C11.4516 12.436 11.1599 12.9704 10.7344 13.3875C11.491 13.9028 12.0342 14.676 12.2625 15.5625ZM8.63437 12.75C8.93105 12.75 9.22106 12.662 9.46773 12.4972C9.7144 12.3324 9.90666 12.0981 10.0202 11.824C10.1337 11.5499 10.1634 11.2483 10.1056 10.9574C10.0477 10.6664 9.90481 10.3991 9.69504 10.1893C9.48526 9.97956 9.21798 9.8367 8.92701 9.77882C8.63604 9.72094 8.33444 9.75065 8.06035 9.86418C7.78626 9.97771 7.55199 10.17 7.38717 10.4166C7.22235 10.6633 7.13438 10.9533 7.13437 11.25C7.13437 11.6478 7.29241 12.0294 7.57372 12.3107C7.85502 12.592 8.23655 12.75 8.63437 12.75ZM21.75 5.25V18.75C21.75 19.1478 21.592 19.5294 21.3107 19.8107C21.0294 20.092 20.6478 20.25 20.25 20.25H3.75C3.35218 20.25 2.97064 20.092 2.68934 19.8107C2.40804 19.5294 2.25 19.1478 2.25 18.75V5.25C2.25 4.85218 2.40804 4.47064 2.68934 4.18934C2.97064 3.90804 3.35218 3.75 3.75 3.75H20.25C20.6478 3.75 21.0294 3.90804 21.3107 4.18934C21.592 4.47064 21.75 4.85218 21.75 5.25ZM20.25 18.75V5.25H3.75V18.75H20.25Z" fill="#0099FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 19H6.4L15.025 10.375L13.625 8.975L5 17.6V19ZM19.3 8.925L15.05 4.725L16.45 3.325C16.8333 2.94167 17.3043 2.75 17.863 2.75C18.421 2.75 18.8917 2.94167 19.275 3.325L20.675 4.725C21.0583 5.10833 21.2583 5.571 21.275 6.113C21.2917 6.65433 21.1083 7.11667 20.725 7.5L19.3 8.925ZM4 21C3.71667 21 3.47933 20.904 3.288 20.712C3.096 20.5207 3 20.2833 3 20V17.175C3 17.0417 3.025 16.9127 3.075 16.788C3.125 16.6627 3.2 16.55 3.3 16.45L13.6 6.15L17.85 10.4L7.55 20.7C7.45 20.8 7.33767 20.875 7.213 20.925C7.08767 20.975 6.95833 21 6.825 21H4ZM14.325 9.675L13.625 8.975L15.025 10.375L14.325 9.675Z" fill="#64748B"/>
</svg>

Before

Width:  |  Height:  |  Size: 718 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 19H6.4L15.025 10.375L13.625 8.975L5 17.6V19ZM19.3 8.925L15.05 4.725L16.45 3.325C16.8333 2.94167 17.3043 2.75 17.863 2.75C18.421 2.75 18.8917 2.94167 19.275 3.325L20.675 4.725C21.0583 5.10833 21.2583 5.571 21.275 6.113C21.2917 6.65433 21.1083 7.11667 20.725 7.5L19.3 8.925ZM4 21C3.71667 21 3.47933 20.904 3.288 20.712C3.096 20.5207 3 20.2833 3 20V17.175C3 17.0417 3.025 16.9127 3.075 16.788C3.125 16.6627 3.2 16.55 3.3 16.45L13.6 6.15L17.85 10.4L7.55 20.7C7.45 20.8 7.33767 20.875 7.213 20.925C7.08767 20.975 6.95833 21 6.825 21H4ZM14.325 9.675L13.625 8.975L15.025 10.375L14.325 9.675Z" fill="#0099FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 718 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 5.27L3.28 4L20 20.72L18.73 22L15.65 18.92C14.5 19.3 13.28 19.5 12 19.5C7 19.5 2.73 16.39 1 12C1.69 10.24 2.79 8.69 4.19 7.46L2 5.27ZM12 9C12.7956 9 13.5587 9.31607 14.1213 9.87868C14.6839 10.4413 15 11.2044 15 12C15.0005 12.3406 14.943 12.6787 14.83 13L11 9.17C11.3213 9.05698 11.6594 8.99949 12 9ZM12 4.5C17 4.5 21.27 7.61 23 12C22.1839 14.0732 20.7969 15.8727 19 17.19L17.58 15.76C18.9629 14.8034 20.0782 13.5091 20.82 12C20.0116 10.3499 18.7564 8.95977 17.1973 7.9875C15.6381 7.01524 13.8375 6.49988 12 6.5C10.91 6.5 9.84 6.68 8.84 7L7.3 5.47C8.74 4.85 10.33 4.5 12 4.5ZM3.18 12C3.98844 13.6501 5.24357 15.0402 6.80273 16.0125C8.36189 16.9848 10.1625 17.5001 12 17.5C12.69 17.5 13.37 17.43 14 17.29L11.72 15C11.0242 14.9254 10.3748 14.6149 9.87997 14.12C9.38512 13.6252 9.07458 12.9758 9 12.28L5.6 8.87C4.61 9.72 3.78 10.78 3.18 12Z" fill="#64748B"/>
</svg>

Before

Width:  |  Height:  |  Size: 969 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 9C12.7956 9 13.5587 9.31607 14.1213 9.87868C14.6839 10.4413 15 11.2044 15 12C15 12.7956 14.6839 13.5587 14.1213 14.1213C13.5587 14.6839 12.7956 15 12 15C11.2044 15 10.4413 14.6839 9.87868 14.1213C9.31607 13.5587 9 12.7956 9 12C9 11.2044 9.31607 10.4413 9.87868 9.87868C10.4413 9.31607 11.2044 9 12 9ZM12 4.5C17 4.5 21.27 7.61 23 12C21.27 16.39 17 19.5 12 19.5C7 19.5 2.73 16.39 1 12C2.73 7.61 7 4.5 12 4.5ZM3.18 12C3.98825 13.6503 5.24331 15.0407 6.80248 16.0133C8.36165 16.9858 10.1624 17.5013 12 17.5013C13.8376 17.5013 15.6383 16.9858 17.1975 16.0133C18.7567 15.0407 20.0117 13.6503 20.82 12C20.0117 10.3497 18.7567 8.95925 17.1975 7.98675C15.6383 7.01424 13.8376 6.49868 12 6.49868C10.1624 6.49868 8.36165 7.01424 6.80248 7.98675C5.24331 8.95925 3.98825 10.3497 3.18 12Z" fill="#64748B"/>
</svg>

Before

Width:  |  Height:  |  Size: 909 B

View File

@@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 10.0009C20.0001 8.33719 19.5851 6.69976 18.7926 5.23693C18.0002 3.7741 16.8554 2.5321 15.4618 1.62342C14.0683 0.714739 12.47 0.168101 10.8119 0.0330203C9.15382 -0.10206 7.48821 0.178686 5.96597 0.849826C4.44374 1.52097 3.11298 2.5613 2.09425 3.87657C1.07552 5.19185 0.401001 6.74052 0.131798 8.38229C-0.137405 10.0241 0.00721324 11.7071 0.552553 13.2788C1.09789 14.8506 2.02672 16.2615 3.2549 17.3837L3.37255 17.4817C5.19907 19.104 7.55712 20 10 20C12.4429 20 14.8009 19.104 16.6275 17.4817L16.7451 17.3837C17.7716 16.4474 18.5913 15.3071 19.1518 14.0359C19.7123 12.7646 20.0012 11.3903 20 10.0009ZM1.17647 10.0009C1.1745 8.56033 1.52523 7.1412 2.19802 5.86743C2.87081 4.59365 3.84521 3.50398 5.03612 2.69356C6.22703 1.88315 7.59822 1.37664 9.02997 1.21827C10.4617 1.0599 11.9104 1.25448 13.2497 1.78503C14.5889 2.31558 15.7779 3.16596 16.7127 4.26191C17.6476 5.35785 18.3 6.66602 18.6129 8.07219C18.9258 9.47835 18.8896 10.9397 18.5076 12.3287C18.1257 13.7177 17.4095 14.992 16.4216 16.0404C15.495 14.6071 14.114 13.5264 12.5 12.9716C13.3057 12.435 13.9174 11.6532 14.2445 10.7421C14.5716 9.83088 14.5969 8.83861 14.3166 7.91195C14.0362 6.9853 13.4652 6.17346 12.6878 5.59646C11.9105 5.01945 10.9681 4.70792 10 4.70792C9.03193 4.70792 8.08954 5.01945 7.31218 5.59646C6.53483 6.17346 5.96377 6.9853 5.68343 7.91195C5.40309 8.83861 5.42836 9.83088 5.7555 10.7421C6.08264 11.6532 6.69429 12.435 7.5 12.9716C5.88597 13.5264 4.50496 14.6071 3.57843 16.0404C2.03426 14.409 1.17458 12.2473 1.17647 10.0009ZM6.66667 9.21653C6.66667 8.55722 6.86217 7.91271 7.22844 7.36452C7.59471 6.81632 8.1153 6.38906 8.72439 6.13675C9.33348 5.88444 10.0037 5.81843 10.6503 5.94705C11.2969 6.07568 11.8909 6.39317 12.357 6.85937C12.8232 7.32557 13.1407 7.91955 13.2693 8.56619C13.3979 9.21283 13.3319 9.88309 13.0796 10.4922C12.8273 11.1013 12.4001 11.622 11.8519 11.9883C11.3037 12.3545 10.6593 12.5501 10 12.5501C9.11674 12.5475 8.2704 12.1954 7.64584 11.5708C7.02128 10.9462 6.66926 10.0998 6.66667 9.21653ZM4.45098 16.864C5.02766 15.908 5.84156 15.1171 6.81377 14.5681C7.78598 14.0191 8.88352 13.7307 10 13.7307C11.1165 13.7307 12.214 14.0191 13.1862 14.5681C14.1584 15.1171 14.9723 15.908 15.549 16.864C13.9778 18.1327 12.0194 18.8246 10 18.8246C7.98061 18.8246 6.02219 18.1327 4.45098 16.864Z" fill="#0F172A"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,3 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32 16.0014C32.0001 13.3395 31.3361 10.7196 30.0682 8.37909C28.8003 6.03856 26.9685 4.05135 24.7389 2.59747C22.5092 1.14358 19.9521 0.268961 17.2991 0.0528324C14.6461 -0.163296 11.9811 0.285897 9.54556 1.35972C7.10998 2.43355 4.98077 4.09807 3.3508 6.20252C1.72083 8.30696 0.641601 10.7848 0.210876 13.4117C-0.219849 16.0385 0.0115412 18.7313 0.884085 21.2461C1.75663 23.761 3.24275 26.0184 5.20784 27.8139L5.39608 27.9707C8.31851 30.5664 12.0914 32 16 32C19.9086 32 23.6815 30.5664 26.6039 27.9707L26.7922 27.8139C28.4345 26.3158 29.7461 24.4914 30.6428 22.4574C31.5396 20.4233 32.0019 18.2244 32 16.0014ZM1.88236 16.0014C1.8792 13.6965 2.44036 11.4259 3.51683 9.38788C4.5933 7.34985 6.15233 5.60636 8.05779 4.3097C9.96324 3.01303 12.1572 2.20262 14.4479 1.94922C16.7387 1.69583 19.0567 2.00717 21.1995 2.85605C23.3422 3.70493 25.2446 5.06554 26.7404 6.81905C28.2362 8.57255 29.28 10.6656 29.7806 12.9155C30.2812 15.1654 30.2234 17.5036 29.6122 19.7259C29.0011 21.9483 27.8551 23.9873 26.2745 25.6647C24.7921 23.3713 22.5825 21.6422 20 20.7546C21.2891 19.8959 22.2678 18.6452 22.7912 17.1873C23.3146 15.7294 23.3551 14.1418 22.9065 12.6591C22.458 11.1765 21.5443 9.87753 20.3005 8.95433C19.0567 8.03112 17.5489 7.53267 16 7.53267C14.4511 7.53267 12.9433 8.03112 11.6995 8.95433C10.4557 9.87753 9.54202 11.1765 9.09348 12.6591C8.64494 14.1418 8.68537 15.7294 9.2088 17.1873C9.73222 18.6452 10.7109 19.8959 12 20.7546C9.41754 21.6422 7.20793 23.3713 5.72549 25.6647C3.25482 23.0543 1.87932 19.5957 1.88236 16.0014ZM10.6667 14.7464C10.6667 13.6915 10.9795 12.6603 11.5655 11.7832C12.1515 10.9061 12.9845 10.2225 13.959 9.8188C14.9336 9.41511 16.0059 9.30948 17.0405 9.51528C18.075 9.72108 19.0254 10.2291 19.7712 10.975C20.5171 11.7209 21.0251 12.6713 21.2309 13.7059C21.4366 14.7405 21.331 15.8129 20.9274 16.7875C20.5237 17.7621 19.8401 18.5951 18.963 19.1812C18.086 19.7673 17.0548 20.0801 16 20.0801C14.5868 20.0759 13.2326 19.5127 12.2333 18.5133C11.234 17.514 10.6708 16.1597 10.6667 14.7464ZM7.12157 26.9824C8.04426 25.4527 9.34649 24.1873 10.902 23.309C12.4576 22.4306 14.2136 21.9691 16 21.9691C17.7864 21.9691 19.5424 22.4306 21.098 23.309C22.6535 24.1873 23.9557 25.4527 24.8784 26.9824C22.3645 29.0122 19.231 30.1194 16 30.1194C12.769 30.1194 9.6355 29.0122 7.12157 26.9824Z" fill="#0099FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,3 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.1386 8.155C38.4927 6.91052 37.4801 5.8602 36.2172 5.12506C34.9544 4.38992 33.4928 3.99988 32.0009 3.99988C30.509 3.99988 29.0474 4.38992 27.7846 5.12506C26.5217 5.8602 25.5091 6.91052 24.8632 8.155L2.08337 49.619C-0.642023 54.5693 2.8019 61.1427 9.21822 61.1427H54.7808C61.1999 61.1427 64.6382 54.5719 61.9184 49.619L39.1386 8.155ZM32.0009 22.185C32.7453 22.185 33.4592 22.4586 33.9856 22.9457C34.512 23.4327 34.8077 24.0933 34.8077 24.7822V37.7681C34.8077 38.4569 34.512 39.1175 33.9856 39.6046C33.4592 40.0916 32.7453 40.3653 32.0009 40.3653C31.2565 40.3653 30.5426 40.0916 30.0162 39.6046C29.4898 39.1175 29.1941 38.4569 29.1941 37.7681V24.7822C29.1941 24.0933 29.4898 23.4327 30.0162 22.9457C30.5426 22.4586 31.2565 22.185 32.0009 22.185ZM32.0009 44.261C32.7453 44.261 33.4592 44.5347 33.9856 45.0217C34.512 45.5088 34.8077 46.1694 34.8077 46.8582V48.1568C34.8077 48.8456 34.512 49.5062 33.9856 49.9933C33.4592 50.4804 32.7453 50.754 32.0009 50.754C31.2565 50.754 30.5426 50.4804 30.0162 49.9933C29.4898 49.5062 29.1941 48.8456 29.1941 48.1568V46.8582C29.1941 46.1694 29.4898 45.5088 30.0162 45.0217C30.5426 44.5347 31.2565 44.261 32.0009 44.261Z" fill="#FF3366"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +0,0 @@
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 13L19 9M19 9L15 5M19 9H5M11 13V14C11 14.7956 10.6839 15.5587 10.1213 16.1213C9.55871 16.6839 8.79565 17 8 17H4C3.20435 17 2.44129 16.6839 1.87868 16.1213C1.31607 15.5587 1 14.7956 1 14V4C1 3.20435 1.31607 2.44129 1.87868 1.87868C2.44129 1.31607 3.20435 1 4 1H8C8.79565 1 9.55871 1.31607 10.1213 1.87868C10.6839 2.44129 11 3.20435 11 4V5" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 539 B

View File

@@ -1,4 +0,0 @@
<svg width="35" height="16" viewBox="0 0 35 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M34.11 14.49L30.19 7.87L34.07 1.52C34.1621 1.36868 34.2124 1.1956 34.2157 1.01848C34.2189 0.841351 34.1751 0.666534 34.0886 0.511912C34.0022 0.35729 33.8762 0.228414 33.7236 0.138469C33.5709 0.0485231 33.3972 0.000737145 33.22 0H2C1.46957 0 0.960859 0.210714 0.585786 0.585787C0.210714 0.96086 0 1.46957 0 2L0 14C0 14.5304 0.210714 15.0391 0.585786 15.4142C0.960859 15.7893 1.46957 16 2 16H33.25C33.4265 16 33.5999 15.9532 33.7524 15.8645C33.905 15.7758 34.0314 15.6483 34.1188 15.4949C34.2061 15.3415 34.2513 15.1678 34.2498 14.9913C34.2482 14.8148 34.2 14.6418 34.11 14.49ZM10.51 11.18H9.39L6.13 6.84V11.19H5V5H6.13L9.4 9.35V5H10.52L10.51 11.18ZM16.84 6H13.31V7.49H16.51V8.49H13.31V10.1H16.84V11.1H12.18V5H16.83L16.84 6ZM25.13 11.16H24L22.45 6.57L20.9 11.18H19.78L17.78 5H19L20.32 9.43L21.84 5H23.06L24.52 9.43L25.85 5H27.08L25.13 11.16Z" fill="#FFCC44"/>
<path d="M34.11 14.49L30.19 7.87L34.07 1.52C34.1621 1.36868 34.2124 1.1956 34.2157 1.01848C34.2189 0.841351 34.1751 0.666534 34.0886 0.511912C34.0022 0.35729 33.8762 0.228414 33.7236 0.138469C33.5709 0.0485231 33.3972 0.000737145 33.22 0H2C1.46957 0 0.960859 0.210714 0.585786 0.585787C0.210714 0.96086 0 1.46957 0 2L0 14C0 14.5304 0.210714 15.0391 0.585786 15.4142C0.960859 15.7893 1.46957 16 2 16H33.25C33.4265 16 33.5999 15.9532 33.7524 15.8645C33.905 15.7758 34.0314 15.6483 34.1188 15.4949C34.2061 15.3415 34.2513 15.1678 34.2498 14.9913C34.2482 14.8148 34.2 14.6418 34.11 14.49ZM10.51 11.18H9.39L6.13 6.84V11.19H5V5H6.13L9.4 9.35V5H10.52L10.51 11.18ZM16.84 6H13.31V7.49H16.51V8.49H13.31V10.1H16.84V11.1H12.18V5H16.83L16.84 6ZM25.13 11.16H24L22.45 6.57L20.9 11.18H19.78L17.78 5H19L20.32 9.43L21.84 5H23.06L24.52 9.43L25.85 5H27.08L25.13 11.16Z" fill="#FFAA44"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 19C15.1944 19 19 15.1944 19 10.5C19 5.80558 15.1944 2 10.5 2C5.80558 2 2 5.80558 2 10.5C2 15.1944 5.80558 19 10.5 19Z" stroke="#4E5969" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 21L17 17" stroke="#4E5969" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 430 B

View File

@@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.7081 18.2926C19.801 18.3855 19.8747 18.4958 19.9249 18.6172C19.9752 18.7386 20.0011 18.8687 20.0011 19.0001C20.0011 19.1315 19.9752 19.2616 19.9249 19.383C19.8747 19.5044 19.801 19.6147 19.7081 19.7076C19.6151 19.8005 19.5048 19.8742 19.3835 19.9245C19.2621 19.9747 19.132 20.0006 19.0006 20.0006C18.8692 20.0006 18.7391 19.9747 18.6177 19.9245C18.4963 19.8742 18.386 19.8005 18.2931 19.7076L10.0006 11.4138L1.70806 19.7076C1.52042 19.8952 1.26592 20.0006 1.00056 20.0006C0.735192 20.0006 0.480697 19.8952 0.293056 19.7076C0.105415 19.5199 5.23096e-09 19.2654 0 19.0001C-5.23096e-09 18.7347 0.105415 18.4802 0.293056 18.2926L8.58681 10.0001L0.293056 1.70757C0.105415 1.51993 -1.97712e-09 1.26543 0 1.00007C1.97712e-09 0.734704 0.105415 0.480208 0.293056 0.292568C0.480697 0.104927 0.735192 -0.000488279 1.00056 -0.000488281C1.26592 -0.000488283 1.52042 0.104927 1.70806 0.292568L10.0006 8.58632L18.2931 0.292568C18.4807 0.104927 18.7352 -0.000488286 19.0006 -0.000488281C19.2659 -0.000488276 19.5204 0.104927 19.7081 0.292568C19.8957 0.480208 20.0011 0.734704 20.0011 1.00007C20.0011 1.26543 19.8957 1.51993 19.7081 1.70757L11.4143 10.0001L19.7081 18.2926Z" fill="#64748B"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,16 +1,6 @@
/* The Lucia project.
Copyright 2023-2026 DSP, inc. All rights reserved.
Authors:
chiayin.kuo@dsp.im (chiayin), 2023/4/18
Layout styles for navbar and heading elements. */
/* Navbar */ /* Navbar */
nav ul>li { nav ul>li {
@apply px-2 py-3.5 duration-300 hover:bg-neutral-900 hover:text-neutral-10 ; @apply px-2 py-3.5 duration-300 hover:bg-neutral-900 hover:text-neutral-10 active:bg-neutral-900 active:text-neutral-10;
}
nav ul>li.active {
@apply bg-neutral-900 text-neutral-10 duration-300;
} }
/* Header */ /* Header */

View File

@@ -1,14 +1,5 @@
/* The Lucia project.
Copyright 2023-2026 DSP, inc. All rights reserved.
Authors:
chiayin.kuo@dsp.im (chiayin), 2023/1/31
cindy.chang@dsp.im (Cindy Chang), 2024/6/18
Main CSS entry point that imports all stylesheet modules. */
@import './tailwind.css'; @import './tailwind.css';
@import './base.css'; @import './base.css';
@import './components.css'; @import './components.css';
@import './layout.css'; @import './layout.css';
@import './vendors.css'; @import './vendors.css';
@import './zindex.css';

View File

@@ -1,23 +0,0 @@
<svg width="40" height="24" viewBox="0 0 40 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3442_4283)">
<rect width="40" height="24" rx="12" fill="#C9CDD4"/>
<g filter="url(#filter0_d_3442_4283)">
<circle cx="12" cy="12" r="10" fill="white"/>
</g>
</g>
<rect x="1" y="1" width="38" height="22" rx="11" stroke="#64748B" stroke-width="2"/>
<defs>
<filter id="filter0_d_3442_4283" x="0" y="2" width="24" height="24" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3442_4283"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3442_4283" result="shape"/>
</filter>
<clipPath id="clip0_3442_4283">
<rect width="40" height="24" rx="12" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,23 +0,0 @@
<svg width="40" height="24" viewBox="0 0 40 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3442_4281)">
<rect width="40" height="24" rx="12" fill="#0099FF"/>
<g filter="url(#filter0_d_3442_4281)">
<circle cx="28" cy="12" r="10" fill="white"/>
</g>
</g>
<rect x="1" y="1" width="38" height="22" rx="11" stroke="#0080D5" stroke-width="2"/>
<defs>
<filter id="filter0_d_3442_4281" x="16" y="2" width="24" height="24" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3442_4281"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3442_4281" result="shape"/>
</filter>
<clipPath id="clip0_3442_4281">
<rect width="40" height="24" rx="12" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,84 +1,6 @@
/* The Lucia project. /* 引入 Google fonts */
Copyright 2023-2026 DSP, inc. All rights reserved.
Authors:
chiayin.kuo@dsp.im (chiayin), 2023/1/31
imacat.yang@dsp.im (imacat), 2026/3/6
Tailwind CSS theme configuration with custom colors,
font sizes, breakpoints, and animations. */
/* Import Google fonts */
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap');
@import "tailwindcss" layer(tailwind-base); @tailwind base;
@tailwind components;
@layer tailwind-base, primevue; @tailwind utilities;
@theme {
--color-*: initial;
--breakpoint-*: initial;
--font-size-*: initial;
--color-transparent: transparent;
--color-current: currentColor;
--color-primary: #0099FF;
--color-secondary: #FFAA44;
--color-cfm-primary: #0099FF;
--color-cfm-secondary: #FFAA44;
--color-neutral-10: #ffffff;
--color-neutral-50: #f8fafc;
--color-neutral-100: #f1f5f9;
--color-neutral-200: #e2e8f0;
--color-neutral-300: #cbd5e1;
--color-neutral-400: #94a3b8;
--color-neutral-500: #64748b;
--color-neutral-600: #475569;
--color-neutral-700: #334155;
--color-neutral-800: #1e293b;
--color-neutral-900: #0f172a;
--color-danger: #FF3366;
--container-center: true;
--container-padding: 16px;
--font-size-xs: 12px;
--font-size-xs--line-height: 1;
--font-size-sm: 14px;
--font-size-sm--line-height: 1;
--font-size-base: 16px;
--font-size-base--line-height: 1;
--font-size-lg: 18px;
--font-size-lg--line-height: 1;
--font-size-xl: 20px;
--font-size-xl--line-height: 1;
--font-size-2xl: 24px;
--font-size-2xl--line-height: 1;
--font-size-3xl: 30px;
--font-size-3xl--line-height: 1;
--font-size-4xl: 36px;
--font-size-4xl--line-height: 1;
--breakpoint-2xl: 1536px;
--animate-fadein: fadein 1s ease-in-out;
--animate-fadeout: fadeout 1s ease-in-out;
--animate-edit: edit 0.6s ease;
}
@keyframes fadein {
0% { opacity: 0; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
@keyframes fadeout {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 0; }
}
@keyframes edit {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(9deg); }
75% { transform: rotate(-9deg); }
}

View File

@@ -1,12 +1,3 @@
/* The Lucia project.
Copyright 2023-2026 DSP, inc. All rights reserved.
Authors:
chiayin.kuo@dsp.im (chiayin), 2023/3/14
cindy.chang@dsp.im (Cindy Chang), 2024/7/26
Third-party vendor style overrides for Google Material
Icons, vue-toast-notification, and PrimeVue components. */
/* import Google font icon */ /* import Google font icon */
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200'); @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
@@ -18,95 +9,26 @@
'GRAD' 0, 'GRAD' 0,
'opsz' 40 'opsz' 40
} }
.material-fill {
font-variation-settings:
'FILL' 1,
'wght' 400,
'GRAD' 0,
'opsz' 40
}
/* vue-toast-notification */
.v-toast {
@apply z-[99999]
}
.v-toast__item {
@apply min-h-[48px] rounded-full
}
.v-toast__item .v-toast__text {
@apply py-3
}
/* Primevue */ /* Primevue */
/* sidebar */
.p-sidebar-left { .p-sidebar-left {
@apply ml-14 @apply ml-14
} }
.p-sidebar-mask { .p-sidebar-mask {
height: calc(100vh - 104px) !important; height: calc(100vh - 104px) !important;
top: 104px !important; top: 104px !important;
z-index: 20;
} }
.p-sidebar { .p-sidebar {
@apply !shadow-[1px_0px_4px_rgba(0,0,0,0.25)] @apply !shadow-[1px_0px_4px_rgba(0,0,0,0.25)]
} }
.p-sidebar-header { .p-sidebar-header {
@apply bg-neutral-200 border-b border-neutral-300 !py-2 !justify-between @apply bg-neutral-200 border-b border-neutral-300 !py-2 !justify-between
} };
.p-sidebar-right .p-sidebar-header { .p-sidebar-right .p-sidebar-header {
@apply flex-row-reverse !justify-end text-neutral-500 @apply flex-row-reverse !justify-end text-neutral-500
} }
.p-sidebar-right .p-sidebar { .p-sidebar-right .p-sidebar {
@apply !shadow-[-1px_0px_4px_rgba(0,0,0,0.25)] @apply !shadow-[-1px_0px_4px_rgba(0,0,0,0.25)]
} }
/* inputswitch */
.p-inputswitch {
@apply !w-11 !h-6
}
.p-inputswitch.p-inputswitch-checked .p-inputswitch-slider {
@apply !bg-primary
}
.p-inputswitch .p-inputswitch-slider:before {
@apply !w-5 !h-5 !left-0.5
}
/* slider */
.p-slider .p-slider-handle {
@apply !h-3.5 !w-3.5 !border !border-primary
}
.p-slider.p-slider-horizontal .p-slider-handle {
@apply !-my-2
}
.p-slider.p-slider-horizontal {
@apply !h-1
}
/* radio */
/* p-radiobutton
p-radiobutton-box
p-radiobutton-icon */
.p-radiobutton {
@apply !align-text-top
}
.p-radiobutton-box {
@apply !bg-neutral-10
}
.p-radiobutton-box .p-highlight:not(.p-disabled):hover {
@apply !bg-neutral-10
}
.p-radiobutton-icon {
@apply !bg-primary
}
/* Dialog */
.p-dialog-header {
@apply !p-0 !px-4 !bg-neutral-100
}
.p-dialog-content {
@apply !p-0
}
/* DataTable */
.p-datatable-resizable > .p-datatable-wrapper {
@apply !overflow-x-visible
}
/* Override inline style overflow-auto so that thead can be positioned */
.p-datatable-wrapper {
overflow: unset !important;
}

View File

@@ -1,11 +0,0 @@
/* The Lucia project.
Copyright 2024-2026 DSP, inc. All rights reserved.
Authors:
cindy.chang@dsp.im (Cindy Chang), 2024/6/18
Z-index and positioning rules for the main content area. */
main.w-full {
z-index: 1;
position: absolute; /*if it were static, the acct mgmt menu would be overlapped*/
}

View File

@@ -1,188 +0,0 @@
<template>
<div
id="account_menu"
v-if="isAcctMenuOpen"
class="absolute top-0 w-[232px] bg-white right-[0px] rounded shadow-lg bg-[#ffffff]"
>
<div id="greeting" class="w-full border-b border-[#CBD5E1]">
<span class="m-4 h-[48px]">
{{ i18next.t("AcctMgmt.hi") }}{{ userData.name }}
</span>
</div>
<ul class="w-full min-h-10">
<!-- Not using a loop here because SVGs won't display if src is a variable -->
<li
v-if="isAdmin"
id="btn_acct_mgmt"
class="w-full h-[40px] flex py-2 px-4 hover:text-[#000000] hover:bg-[#F1F5F9] cursor-pointer items-center"
@click="onBtnAcctMgmtClick"
>
<span class="w-[24px] h-[24px] flex"
><img src="@/assets/icon-crown.svg" alt="accountManagement"
/></span>
<span class="flex ml-[8px]">{{ i18next.t("AcctMgmt.acctMgmt") }}</span>
</li>
<li
id="btn_mang_ur_acct"
class="w-full h-[40px] flex py-2 px-4 hover:text-[#000000] hover:bg-[#F1F5F9] cursor-pointer items-center"
@click="onBtnMyAccountClick"
>
<span class="w-[24px] h-[24px] flex"
><img src="@/assets/icon-head-black.svg" alt="head-black"
/></span>
<span class="flex ml-[8px]">{{
i18next.t("AcctMgmt.mangUrAcct")
}}</span>
</li>
<li
id="btn_logout_in_menu"
class="w-full h-[40px] flex py-2 px-4 hover:text-[#000000] hover:bg-[#F1F5F9] cursor-pointer items-center"
@click="onLogoutBtnClick"
>
<span class="w-[24px] h-[24px] flex"
><img src="@/assets/icon-logout.svg" alt="logout"
/></span>
<span class="flex ml-[8px]">{{ i18next.t("AcctMgmt.Logout") }}</span>
</li>
</ul>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2024-2026 DSP, inc. All rights reserved.
// Authors:
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/AccountMenu/AcctMenu Dropdown account menu
* with links to account management, my account, and logout.
*/
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
import { storeToRefs } from "pinia";
import i18next from "@/i18n/i18n";
import { useRouter, useRoute } from "vue-router";
import { useLoginStore } from "@/stores/login";
import { useAcctMgmtStore } from "@/stores/acctMgmt";
import { useAllMapDataStore } from "@/stores/allMapData";
import { useConformanceStore } from "@/stores/conformance";
import { leaveFilter, leaveConformance } from "@/module/alertModal.js";
import emitter from "@/utils/emitter";
const router = useRouter();
const route = useRoute();
const loginStore = useLoginStore();
const allMapDataStore = useAllMapDataStore();
const conformanceStore = useConformanceStore();
const acctMgmtStore = useAcctMgmtStore();
const { logOut } = loginStore;
const { tempFilterId } = storeToRefs(allMapDataStore);
const { conformanceLogTempCheckId, conformanceFilterTempCheckId } =
storeToRefs(conformanceStore);
const { userData } = storeToRefs(loginStore);
const { isAcctMenuOpen } = storeToRefs(acctMgmtStore);
const loginUserData = ref(null);
const currentViewingUserDetail = computed(
() => acctMgmtStore.currentViewingUser.detail,
);
const isAdmin = ref(false);
/** Fetches user data and determines if the current user is an admin. */
const getIsAdminValue = async () => {
try {
await loginStore.getUserData();
loginUserData.value = loginStore.userData;
await acctMgmtStore.getUserDetail(loginUserData.value.username);
isAdmin.value = acctMgmtStore.currentViewingUser.is_admin;
} catch (error) {
console.error("Failed to fetch admin status:", error);
}
};
/** Navigates to the My Account page. */
const onBtnMyAccountClick = async () => {
try {
acctMgmtStore.closeAcctMenu();
await acctMgmtStore.getAllUserAccounts(); // in case we haven't fetched yet
await acctMgmtStore.setCurrentViewingUser(loginUserData.value.username);
await router.push("/my-account");
} catch (error) {
console.error("Failed to navigate to My Account:", error);
}
};
/**
* Closes the menu when clicking outside. Stored as a named
* function so it can be removed in onBeforeUnmount.
* @param {MouseEvent} event - The click event.
*/
const handleDocumentClick = (event) => {
const acctMgmtButton = document.getElementById("acct_mgmt_button");
const acctMgmtMenu = document.getElementById("account_menu");
if (
acctMgmtMenu &&
acctMgmtButton &&
!acctMgmtMenu.contains(event.target) &&
!acctMgmtButton.contains(event.target)
) {
acctMgmtStore.closeAcctMenu();
}
};
/** Registers a click listener to close the menu when clicking outside. */
const clickOtherPlacesThenCloseMenu = () => {
document.addEventListener("click", handleDocumentClick);
};
/** Navigates to the Account Admin page. */
const onBtnAcctMgmtClick = () => {
router.push({ name: "AcctAdmin" });
acctMgmtStore.closeAcctMenu();
};
/** Handles logout with unsaved-changes confirmation for Map and Conformance pages. */
const onLogoutBtnClick = () => {
if (
(route.name === "Map" || route.name === "CheckMap") &&
tempFilterId.value
) {
// Notify Map to close the Sidebar.
emitter.emit("leaveFilter", false);
leaveFilter(false, allMapDataStore.addFilterId, false, logOut);
} else if (
(route.name === "Conformance" || route.name === "CheckConformance") &&
(conformanceLogTempCheckId.value || conformanceFilterTempCheckId.value)
) {
leaveConformance(
false,
conformanceStore.addConformanceCreateCheckId,
false,
logOut,
);
} else {
logOut();
}
};
// created
loginStore.getUserData();
// mounted
onMounted(async () => {
await getIsAdminValue();
clickOtherPlacesThenCloseMenu();
});
onBeforeUnmount(() => {
document.removeEventListener("click", handleDocumentClick);
});
</script>
<style>
#account_menu {
z-index: 1100;
}
</style>

View File

@@ -1,58 +0,0 @@
<template>
<div
id="search_bar_container"
class="flex w-[280px] h-8 px-4 items-center border-[#64748B] border-[1px] rounded-full justify-between"
>
<input
id="input_search"
class="w-full outline-0"
:placeholder="i18next.t('AcctMgmt.Search')"
v-model="inputQuery"
@keypress="handleKeyPressOfSearch"
/>
<img
src="@/assets/icon-search.svg"
class="w-[17px] h-[17px] flex cursor-pointer"
@click="onSearchClick"
alt="search"
/>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2024-2026 DSP, inc. All rights reserved.
// Authors:
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/AccountMenu/SearchBar Search input bar for
* filtering accounts, emits search query on click or Enter key.
*/
import { ref } from "vue";
import i18next from "@/i18n/i18n.js";
const emit = defineEmits(["on-search-account-button-click"]);
const inputQuery = ref("");
/**
* Emits the search query when the search icon is clicked.
* @param {Event} event - The click event.
*/
const onSearchClick = (event) => {
event.preventDefault();
emit("on-search-account-button-click", inputQuery.value);
};
/**
* Emits the search query when Enter key is pressed.
* @param {KeyboardEvent} event - The keypress event.
*/
const handleKeyPressOfSearch = (event) => {
if (event.key === "Enter") {
emit("on-search-account-button-click", inputQuery.value);
}
};
</script>

View File

@@ -1,45 +0,0 @@
<template>
<button
class="button-component w-[80px] h-[32px] rounded-full flex text-[#666666] border-[1px] border-[#666666] justify-center items-center bg-[#FFFFFF] hover:text-[#0099FF] hover:border-[#0099FF] focus:text-[#0099FF] focus:border-[#0099FF] cursor-pointer"
:class="{
ring: isPressed,
'ring-[#0099FF]': isPressed,
'ring-opacity-30': isPressed,
}"
@mousedown="onMousedown"
@mouseup="onMouseup"
>
{{ buttonText }}
</button>
</template>
<script setup>
// The Lucia project.
// Copyright 2024-2026 DSP, inc. All rights reserved.
// Authors:
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Button Outlined button component with
* press-state ring effect.
*/
import { ref } from "vue";
defineProps({
buttonText: {
type: String,
required: false,
},
});
const isPressed = ref(false);
const onMousedown = () => {
isPressed.value = true;
};
const onMouseup = () => {
isPressed.value = false;
};
</script>

View File

@@ -1,40 +0,0 @@
<template>
<form role="search">
<label for="searchFiles" class="mr-4 relative" htmlFor="searchFiles">
Search
<input
type="search"
id="searchFiles"
placeholder="Search Activity"
class="px-5 py-2 w-52 rounded-full text-sm align-middle duration-300 border bg-neutral-100 border-neutral-300 hover:border-neutral-500 focus:outline-none focus:ring focus:border-neutral-500"
/>
<span
class="absolute top-2 bottom-0.5 right-0.5 flex justify-center items-center gap-2"
>
<IconSetting class="w-6 h-6 cursor-pointer"></IconSetting>
<span
class="w-px h-6 block after:border after:border-neutral-300 after:content-['']"
></span>
<button class="pr-2">
<IconSearch class="w-6 h-6"></IconSearch>
</button>
</span>
</label>
</form>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Search Search bar component with activity
* search input, settings icon, and search icon button.
*/
import IconSearch from "@/components/icons/IconSearch.vue";
import IconSetting from "@/components/icons/IconSetting.vue";
</script>

View File

@@ -1,117 +0,0 @@
<template>
<div id="header.vue" class="mx-auto px-4 h-14 z-50">
<div class="flex justify-between items-center h-full">
<figure>
<DspLogo />
</figure>
<div
class="flex justify-between items-center relative"
v-show="showMember"
>
<span id="acct_mgmt_button">
<img
v-if="!isHeadHovered"
src="@/assets/icon-head-black.svg"
@mouseenter="isHeadHovered = true"
width="32"
height="32"
@click="toggleIsAcctMenuOpen"
class="cursor-pointer z-50"
alt="user-head"
/>
<img
v-else
src="@/assets/icon-head-blue.svg"
@mouseleave="isHeadHovered = false"
width="32"
height="32"
@click="toggleIsAcctMenuOpen"
class="cursor-pointer z-50"
alt="user-head"
/>
</span>
</div>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Header Application header with DSP logo and
* user account menu toggle button.
*/
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { storeToRefs } from "pinia";
import emitter from "@/utils/emitter";
import { useLoginStore } from "@/stores/login";
import { useAcctMgmtStore } from "@/stores/acctMgmt";
import DspLogo from "@/components/icons/DspLogo.vue";
import { useAllMapDataStore } from "@/stores/allMapData";
import { useConformanceStore } from "@/stores/conformance";
import { leaveFilter, leaveConformance } from "@/module/alertModal.js";
const route = useRoute();
const store = useLoginStore();
const { logOut } = store;
const allMapDataStore = useAllMapDataStore();
const conformanceStore = useConformanceStore();
const acctMgmtStore = useAcctMgmtStore();
const { tempFilterId, temporaryData, postRuleData, ruleData } =
storeToRefs(allMapDataStore);
const {
conformanceLogTempCheckId,
conformanceFilterTempCheckId,
conformanceFileName,
} = storeToRefs(conformanceStore);
const isHeadHovered = ref(false);
const showMember = ref(false);
const toggleIsAcctMenuOpen = () => {
acctMgmtStore.toggleIsAcctMenuOpen();
};
/**
* Handles logout with unsaved-changes confirmation for Map
* and Conformance pages.
*/
function logOutButton() {
if (
(route.name === "Map" || route.name === "CheckMap") &&
tempFilterId.value
) {
// Notify Map to close the Sidebar.
emitter.emit("leaveFilter", false);
leaveFilter(false, allMapDataStore.addFilterId, false, logOut);
} else if (
(route.name === "Conformance" || route.name === "CheckConformance") &&
(conformanceLogTempCheckId.value || conformanceFilterTempCheckId.value)
) {
leaveConformance(
false,
conformanceStore.addConformanceCreateCheckId,
false,
logOut,
);
} else {
logOut();
}
}
onMounted(() => {
if (route.name === "Login" || route.name === "NotFound404") {
showMember.value = false;
} else {
showMember.value = true;
}
});
</script>

View File

@@ -1,417 +0,0 @@
<template>
<nav id="nav_bar" class="bg-neutral-700">
<div
class="mx-auto px-4"
:class="[showNavbarBreadcrumb ? 'min-h-12' : 'h-12']"
>
<div
class="flex justify-between items-center flex-wrap relative"
v-show="showNavbarBreadcrumb"
>
<div id="nav_bar_logged_in" class="flex flex-1 items-center">
<!-- Back to Files page -->
<router-link to="/files" class="mr-4" v-if="showIcon" id="backPage">
<span
class="material-symbols-outlined text-neutral-10 !leading-loose"
>
arrow_back
</span>
</router-link>
<div>
<h2
v-if="navViewName !== 'UPLOAD'"
class="mr-14 py-3 text-2xl font-black text-neutral-10"
>
{{ navViewName }}
</h2>
<h2 v-else class="mr-14 py-3 text-2xl font-black text-neutral-10">
FILES
</h2>
</div>
<ul
class="flex justify-center items-center space-x-4 text-xl font-semibold text-neutral-300 cursor-pointer"
>
<li
@click="onNavItemBtnClick($event, item)"
v-for="(item, index) in navViewData[navViewName]"
:key="index"
class="nav-item"
:class="{ active: activePage === item }"
>
{{ item }}
</li>
</ul>
</div>
<!-- Files Page: Search and Upload -->
<div
class="flex justify-end items-center"
v-if="navViewName === 'FILES'"
>
<div
id="import_btn"
class="btn btn-sm btn-neutral cursor-pointer"
@click="uploadModal = true"
>
Import
<UploadModal
:visible="uploadModal"
@closeModal="uploadModal = $event"
></UploadModal>
</div>
</div>
<!-- Upload, Performance, Compare have no button actions -->
<div v-else-if="noShowSaveButton"></div>
<!-- Other Page: Save and Download -->
<!-- Save: if data exists, prompt rename; if no data, prompt save; if unchanged, do nothing -->
<div v-else class="space-x-4">
<button
class="btn btn-sm"
:class="[disabledSave ? 'btn-disable' : 'btn-neutral']"
:disabled="disabledSave"
@click="saveModal"
>
Save
</button>
</div>
<AcctMenu v-if="showNavbarBreadcrumb" />
</div>
</div>
</nav>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Navbar Navigation bar with breadcrumb-style
* page tabs, import button for Files, and save button for
* Map/Conformance pages.
*/
import { ref, computed, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import emitter from "@/utils/emitter";
import { useFilesStore } from "@/stores/files";
import { useAllMapDataStore } from "@/stores/allMapData";
import { useConformanceStore } from "@/stores/conformance";
import { usePageAdminStore } from "@/stores/pageAdmin";
import { useMapCompareStore } from "@/stores/mapCompareStore";
import {
saveFilter,
savedSuccessfully,
saveConformance,
} from "@/module/alertModal.js";
import UploadModal from "./File/UploadModal.vue";
import AcctMenu from "./AccountMenu/AcctMenu.vue";
const route = useRoute();
const router = useRouter();
const store = useFilesStore();
const allMapDataStore = useAllMapDataStore();
const conformanceStore = useConformanceStore();
const mapCompareStore = useMapCompareStore();
const pageAdminStore = usePageAdminStore();
const {
logId,
tempFilterId,
createFilterId,
filterName,
postRuleData,
isUpdateFilter,
} = storeToRefs(allMapDataStore);
const {
conformanceRuleData,
conformanceLogId,
conformanceFilterId,
conformanceLogTempCheckId,
conformanceFilterTempCheckId,
conformanceLogCreateCheckId,
conformanceFilterCreateCheckId,
isUpdateConformance,
conformanceFileName,
} = storeToRefs(conformanceStore);
const {
activePage,
pendingActivePage,
activePageComputedByRoute,
shouldKeepPreviousPage,
} = storeToRefs(pageAdminStore);
const {
setPendingActivePage,
setPreviousPage,
setActivePage,
setActivePageComputedByRoute,
setIsPagePendingBoolean,
} = pageAdminStore;
const showNavbarBreadcrumb = ref(false);
const navViewData = {
// e.g. FILES: ['ALL', 'DISCOVER', 'COMPARE', 'DESIGN', 'SIMULATION'],
FILES: ["ALL", "DISCOVER", "COMPARE"],
// e.g. DISCOVER: ['MAP', 'CONFORMANCE', 'PERFORMANCE', 'DATA']
DISCOVER: ["MAP", "CONFORMANCE", "PERFORMANCE"],
// e.g. COMPARE: ['PROCESS MAP', 'DASHBOARD']
COMPARE: ["MAP", "PERFORMANCE"],
"ACCOUNT MANAGEMENT": [],
"MY ACCOUNT": [],
};
const navViewName = ref("FILES");
const uploadModal = ref(false);
const disabledSave = computed(() => {
switch (route.name) {
case "Map":
case "CheckMap":
// Cannot save without a filter ID or a temporary tempFilterId
return !tempFilterId.value;
case "Conformance":
case "CheckConformance":
return !(
conformanceFilterTempCheckId.value || conformanceLogTempCheckId.value
);
default:
return true;
}
});
const showIcon = computed(() => {
return !["FILES", "UPLOAD"].includes(navViewName.value);
});
const noShowSaveButton = computed(() => {
return (
navViewName.value === "UPLOAD" ||
navViewName.value === "COMPARE" ||
navViewName.value === "ACCOUNT MANAGEMENT" ||
activePage.value === "PERFORMANCE"
);
});
watch(
() => route,
() => {
getNavViewName();
},
{ deep: true },
);
/**
* Handles navigation when a navbar tab is clicked.
* @param {Event} event - The click event from the nav item.
*/
function onNavItemBtnClick(event) {
let type;
let fileId;
let isCheckPage;
const navItemCandidate = event.target.innerText;
setPendingActivePage(navItemCandidate);
switch (navViewName.value) {
case "FILES":
store.filesTag = navItemCandidate;
break;
case "DISCOVER": {
type = route.params.type;
fileId = route.params.fileId;
isCheckPage = route.name.includes("Check");
const discoverRoutes = {
MAP: "Map",
CONFORMANCE: "Conformance",
PERFORMANCE: "Performance",
};
const baseName = discoverRoutes[navItemCandidate];
if (baseName) {
const routeName = isCheckPage ? `Check${baseName}` : baseName;
router.push({
name: routeName,
params: { type: type, fileId: fileId },
});
}
break;
}
case "COMPARE":
switch (navItemCandidate) {
case "MAP":
router.push({
name: "MapCompare",
params: mapCompareStore.routeParam,
});
break;
case "PERFORMANCE":
router.push({
name: "CompareDashboard",
params: mapCompareStore.routeParam,
});
break;
default:
break;
}
}
}
/**
* Determines the navbar view name and active page from the current route.
* @returns {string} The navigation item name to highlight.
*/
function getNavViewName() {
const name = route.name;
let valueToSet;
if (route.name === "NotFound404" || !route.matched[1]) {
return;
}
// route.matched[1] is the second matched route record for the current route
navViewName.value = route.matched[1].name.toUpperCase();
store.filesTag = "ALL";
switch (navViewName.value) {
case "FILES":
valueToSet = activePage.value;
break;
case "DISCOVER":
switch (name) {
case "Map":
case "CheckMap":
valueToSet = "MAP";
break;
case "Conformance":
case "CheckConformance":
valueToSet = "CONFORMANCE";
break;
case "Performance":
case "CheckPerformance":
valueToSet = "PERFORMANCE";
break;
}
break;
case "COMPARE":
switch (name) {
case "MapCompare":
valueToSet = "MAP";
break;
case "CompareDashboard":
valueToSet = "PERFORMANCE";
break;
default:
break;
}
break;
}
// Frontend is not sure which button will the user press on the modal,
// so here we need to save to a pending state
// The frontend cannot determine which modal button the user will press
// (cancel or confirm/save), so we save it to a pending state.
if (!shouldKeepPreviousPage.value) {
// If the user did not press cancel
setPendingActivePage(valueToSet);
}
return valueToSet;
}
/** Opens the save modal for Map or Conformance pages. */
async function saveModal() {
// Help determine MAP/CONFORMANCE save with "submit" or "cancel".
// Notify Map to close the Sidebar.
emitter.emit("saveModal", false);
switch (route.name) {
case "Map":
await handleMapSave();
break;
case "CheckMap":
await handleCheckMapSave();
break;
case "Conformance":
case "CheckConformance":
await handleConformanceSave();
break;
default:
break;
}
}
/** Sets nav item button background color when the active page is empty. */
function handleNavItemBtn() {
if (activePageComputedByRoute.value === "") {
setActivePageComputedByRoute(route.matched[route.matched.length - 1].name);
}
}
/** Saves or creates a filter for the Map page. */
async function handleMapSave() {
if (createFilterId.value) {
await allMapDataStore.updateFilter();
if (isUpdateFilter.value) {
await savedSuccessfully(filterName.value);
}
} else if (logId.value) {
const isSaved = await saveFilter(allMapDataStore.addFilterId);
if (isSaved) {
setActivePage("MAP");
await router.push(`/discover/filter/${createFilterId.value}/map`);
}
}
}
/** Saves a filter from the Check Map page. */
async function handleCheckMapSave() {
const isSaved = await saveFilter(allMapDataStore.addFilterId);
if (isSaved) {
setActivePage("MAP");
await router.push(`/discover/filter/${createFilterId.value}/map`);
}
}
/** Saves or updates conformance check data. */
async function handleConformanceSave() {
if (
conformanceFilterCreateCheckId.value ||
conformanceLogCreateCheckId.value
) {
await conformanceStore.updateConformance();
if (isUpdateConformance.value) {
await savedSuccessfully(conformanceFileName.value);
}
} else {
const isSaved = await saveConformance(
conformanceStore.addConformanceCreateCheckId,
);
if (isSaved) {
if (conformanceLogId.value) {
setActivePage("CONFORMANCE");
await router.push(
`/discover/conformance/log/${conformanceLogCreateCheckId.value}/conformance`,
);
} else if (conformanceFilterId.value) {
setActivePage("CONFORMANCE");
await router.push(
`/discover/conformance/filter/${conformanceFilterCreateCheckId.value}/conformance`,
);
}
}
}
}
onMounted(() => {
handleNavItemBtn();
if (route.params.type === "filter") {
createFilterId.value = route.params.fileId;
}
showNavbarBreadcrumb.value = route.matched[0].name !== "AuthContainer";
getNavViewName();
});
</script>
<style scoped>
#searchFiles::-webkit-search-cancel-button {
appearance: none;
}
</style>

View File

@@ -1,47 +0,0 @@
<!-- The filled version of the button has a solid background -->
<template>
<button
class="button-filled-component w-[80px] h-[32px] rounded-full flex text-[#FFFFFF] justify-center items-center bg-[#0099FF] hover:text-[#FFFFFF] hover:bg-[#0080D5] cursor-pointer"
:class="{
ring: isPressed,
'ring-[#0099FF]': isPressed,
'ring-opacity-30': isPressed,
'bg-[#0099FF]': isPressed,
}"
@mousedown="onMousedown"
@mouseup="onMouseup"
>
{{ buttonText }}
</button>
</template>
<script setup>
// The Lucia project.
// Copyright 2024-2026 DSP, inc. All rights reserved.
// Authors:
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/ButtonFilled Filled button component with
* solid background and press-state ring effect.
*/
import { ref } from "vue";
defineProps({
buttonText: {
type: String,
required: false,
},
});
const isPressed = ref(false);
const onMousedown = () => {
isPressed.value = true;
};
const onMouseup = () => {
isPressed.value = false;
};
</script>

View File

@@ -1,441 +0,0 @@
<template>
<Drawer
:visible="sidebarState"
:closeIcon="'pi pi-angle-right'"
:modal="false"
position="right"
:dismissable="false"
class="!w-[440px]"
@hide="hide"
@show="show"
>
<template #header>
<p class="pl-2 text-base font-bold text-neutral-900">Summary</p>
</template>
<!-- header: summary -->
<div v-if="primaryStatData && secondaryStatData" class="flex justify-start items-start">
<!-- 001 -->
<section class="w-[204px] box-border pr-4">
<div class="mb-4">
<p class="h2">File Name</p>
<p
class="text-sm leading-normal whitespace-nowrap break-keep overflow-hidden text-ellipsis"
:title="primaryStatData.name"
>
{{ primaryStatData.name }}
</p>
</div>
<!-- Stats -->
<ul class="pb-4 border-b border-neutral-300">
<li>
<p class="h2">Cases</p>
<div class="flex justify-between items-center">
<div class="w-full mr-4">
<span class="block text-sm"
>{{ primaryStatData.cases.count }} /
{{ primaryStatData.cases.total }}</span
>
<ProgressBar
:value="primaryValueCases"
:showValue="false"
class="!h-2 !rounded-full my-2 !bg-neutral-300 progressbar-primary"
></ProgressBar>
</div>
<span
class="block text-primary text-2xl text-right font-medium basis-28"
>{{ primaryStatData.cases.ratio }}%</span
>
</div>
</li>
<li>
<p class="h2">Traces</p>
<div class="flex justify-between items-center">
<div class="w-full mr-4">
<span class="block text-sm"
>{{ primaryStatData.traces.count }} /
{{ primaryStatData.traces.total }}</span
>
<ProgressBar
:value="primaryValueTraces"
:showValue="false"
class="!h-2 !rounded-full my-2 !bg-neutral-300 progressbar-primary"
></ProgressBar>
</div>
<span
class="block text-primary text-2xl text-right font-medium basis-28"
>{{ primaryStatData.traces.ratio }}%</span
>
</div>
</li>
<li>
<p class="h2">Activity Instances</p>
<div class="flex justify-between items-center">
<div class="w-full mr-4">
<span class="block text-sm"
>{{ primaryStatData.task_instances.count }} /
{{ primaryStatData.task_instances.total }}</span
>
<ProgressBar
:value="primaryValueTaskInstances"
:showValue="false"
class="!h-2 !rounded-full my-2 !bg-neutral-300 progressbar-primary"
></ProgressBar>
</div>
<span
class="block text-primary text-2xl text-right font-medium basis-28"
>{{ primaryStatData.task_instances.ratio }}%</span
>
</div>
</li>
<li>
<p class="h2">Activities</p>
<div class="flex justify-between items-center">
<div class="w-full mr-4">
<span class="block text-sm"
>{{ primaryStatData.tasks.count }} /
{{ primaryStatData.tasks.total }}</span
>
<ProgressBar
:value="primaryValueTasks"
:showValue="false"
class="!h-2 !rounded-full my-2 !bg-neutral-300 progressbar-primary"
></ProgressBar>
</div>
<span
class="block text-primary text-2xl text-right font-medium basis-28"
>{{ primaryStatData.tasks.ratio }}%</span
>
</div>
</li>
</ul>
<!-- Log Timeframe -->
<div class="pt-1 pb-4 border-b border-neutral-300">
<p class="h2">Log Timeframe</p>
<div class="space-y-2 text-sm text-center">
<span class="block">{{ primaryStatData.started_at }}&nbsp;</span>
<span class="block">~</span>
<span class="block">&nbsp;{{ primaryStatData.completed_at }}</span>
</div>
</div>
<!-- Case Duration -->
<div class="pt-1 pb-4">
<p class="h2">Case Duration</p>
<ul class="space-y-1 text-sm">
<li>
<Tag
value="MIN"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag
>{{ primaryStatData.case_duration.min }}
</li>
<li>
<Tag
value="AVG"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag
>{{ primaryStatData.case_duration.average }}
</li>
<li>
<Tag
value="MED"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag
>{{ primaryStatData.case_duration.median }}
</li>
<li>
<Tag
value="MAX"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag
>{{ primaryStatData.case_duration.max }}
</li>
</ul>
</div>
</section>
<!-- 002 -->
<section class="w-[204px] box-border pl-4">
<div class="mb-4">
<p class="h2">File Name</p>
<p
class="text-sm leading-normal whitespace-nowrap break-keep overflow-hidden text-ellipsis"
:title="secondaryStatData.name"
>
{{ secondaryStatData.name }}
</p>
</div>
<!-- Stats -->
<ul class="pb-4 border-b border-neutral-300">
<li>
<p class="h2">Cases</p>
<div class="flex justify-between items-center">
<div class="w-full mr-4">
<span class="block text-sm"
>{{ secondaryStatData.cases.count }} /
{{ secondaryStatData.cases.total }}</span
>
<ProgressBar
:value="secondaryValueTraces"
:showValue="false"
class="!h-2 !rounded-full my-2 !bg-neutral-300 progressbar-secondary"
></ProgressBar>
</div>
<span
class="block text-secondary text-2xl text-right font-medium basis-28"
>{{ secondaryStatData.cases.ratio }}%</span
>
</div>
</li>
<li>
<p class="h2">Traces</p>
<div class="flex justify-between items-center">
<div class="w-full mr-4">
<span class="block text-sm"
>{{ secondaryStatData.traces.count }} /
{{ secondaryStatData.traces.total }}</span
>
<ProgressBar
:value="secondaryValueTraces"
:showValue="false"
class="!h-2 !rounded-full my-2 !bg-neutral-300 progressbar-secondary"
></ProgressBar>
</div>
<span
class="block text-secondary text-2xl text-right font-medium basis-28"
>{{ secondaryStatData.traces.ratio }}%</span
>
</div>
</li>
<li>
<p class="h2">Activity Instances</p>
<div class="flex justify-between items-center">
<div class="w-full mr-4">
<span class="block text-sm"
>{{ secondaryStatData.task_instances.count }} /
{{ secondaryStatData.task_instances.total }}</span
>
<ProgressBar
:value="secondaryValueTaskInstances"
:showValue="false"
class="!h-2 !rounded-full my-2 !bg-neutral-300 progressbar-secondary"
></ProgressBar>
</div>
<span
class="block text-secondary text-2xl text-right font-medium basis-28"
>{{ secondaryStatData.task_instances.ratio }}%</span
>
</div>
</li>
<li>
<p class="h2">Activities</p>
<div class="flex justify-between items-center">
<div class="w-full mr-4">
<span class="block text-sm"
>{{ secondaryStatData.tasks.count }} /
{{ secondaryStatData.tasks.total }}</span
>
<ProgressBar
:value="secondaryValueTasks"
:showValue="false"
class="!h-2 !rounded-full my-2 !bg-neutral-300 progressbar-secondary"
></ProgressBar>
</div>
<span
class="block text-secondary text-2xl text-right font-medium basis-28"
>{{ secondaryStatData.tasks.ratio }}%</span
>
</div>
</li>
</ul>
<!-- Log Timeframe -->
<div class="pt-1 pb-4 border-b border-neutral-300">
<p class="h2">Log Timeframe</p>
<div class="space-y-2 text-sm text-center">
<span class="block">{{ secondaryStatData.started_at }}&nbsp;</span>
<span class="block">~</span>
<span class="block"
>&nbsp;{{ secondaryStatData.completed_at }}</span
>
</div>
</div>
<!-- Case Duration -->
<div class="pt-1 pb-4">
<p class="h2">Case Duration</p>
<ul class="space-y-1 text-sm">
<li>
<Tag
value="MIN"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag
>{{ secondaryStatData.case_duration.min }}
</li>
<li>
<Tag
value="AVG"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag
>{{ secondaryStatData.case_duration.average }}
</li>
<li>
<Tag
value="MED"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag
>{{ secondaryStatData.case_duration.median }}
</li>
<li>
<Tag
value="MAX"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag
>{{ secondaryStatData.case_duration.max }}
</li>
</ul>
</div>
</section>
</div>
</Drawer>
</template>
<script setup>
// The Lucia project.
// Copyright 2024-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Compare/SidebarStates Summary sidebar for
* the Compare view showing side-by-side statistics (cases,
* traces, activities, timeframes, durations) of two files.
*/
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { useCompareStore } from "@/stores/compare";
import { getTimeLabel } from "@/module/timeLabel.js";
import getMoment from "moment";
const props = defineProps({
sidebarState: {
type: Boolean,
require: false,
},
});
const route = useRoute();
const compareStore = useCompareStore();
const primaryValueCases = ref(0);
const primaryValueTraces = ref(0);
const primaryValueTaskInstances = ref(0);
const primaryValueTasks = ref(0);
const secondaryValueCases = ref(0);
const secondaryValueTraces = ref(0);
const secondaryValueTaskInstances = ref(0);
const secondaryValueTasks = ref(0);
const primaryStatData = ref(null);
const secondaryStatData = ref(null);
/**
* Converts a ratio (01) to a percentage number (0100), capped at 100.
* @param {number} val - The ratio value to convert.
* @returns {number} The percentage value.
*/
const getPercentLabel = (val) => {
if (Number((val * 100).toFixed(1)) >= 100) return 100;
else return Number.parseFloat((val * 100).toFixed(1));
};
/**
* Transforms raw API stats into display-ready stat data.
* @param {Object} data - The raw stats from the API.
* @param {string} fileName - The file name to display.
* @returns {Object} The formatted stat data object.
*/
const getStatData = (data, fileName) => {
return {
name: fileName,
cases: {
count: data.cases.count.toLocaleString("en-US"),
total: data.cases.total.toLocaleString("en-US"),
ratio: getPercentLabel(data.cases.ratio),
},
traces: {
count: data.traces.count.toLocaleString("en-US"),
total: data.traces.total.toLocaleString("en-US"),
ratio: getPercentLabel(data.traces.ratio),
},
task_instances: {
count: data.task_instances.count.toLocaleString("en-US"),
total: data.task_instances.total.toLocaleString("en-US"),
ratio: getPercentLabel(data.task_instances.ratio),
},
tasks: {
count: data.tasks.count.toLocaleString("en-US"),
total: data.tasks.total.toLocaleString("en-US"),
ratio: getPercentLabel(data.tasks.ratio),
},
started_at: getMoment(data.started_at).format("YYYY.MM.DD HH:mm"),
completed_at: getMoment(data.completed_at).format("YYYY.MM.DD HH:mm"),
case_duration: {
min: getTimeLabel(data.case_duration.min, 2),
max: getTimeLabel(data.case_duration.max, 2),
average: getTimeLabel(data.case_duration.average, 2),
median: getTimeLabel(data.case_duration.median, 2),
},
};
};
/** Populates progress bar values when the sidebar is shown. */
const show = () => {
if (!primaryStatData.value || !secondaryStatData.value) return;
primaryValueCases.value = primaryStatData.value.cases.ratio;
primaryValueTraces.value = primaryStatData.value.traces.ratio;
primaryValueTaskInstances.value = primaryStatData.value.task_instances.ratio;
primaryValueTasks.value = primaryStatData.value.tasks.ratio;
secondaryValueCases.value = secondaryStatData.value.cases.ratio;
secondaryValueTraces.value = secondaryStatData.value.traces.ratio;
secondaryValueTaskInstances.value =
secondaryStatData.value.task_instances.ratio;
secondaryValueTasks.value = secondaryStatData.value.tasks.ratio;
};
/** Resets all progress bar values to zero when the sidebar is hidden. */
const hide = () => {
primaryValueCases.value = 0;
primaryValueTraces.value = 0;
primaryValueTaskInstances.value = 0;
primaryValueTasks.value = 0;
secondaryValueCases.value = 0;
secondaryValueTraces.value = 0;
secondaryValueTaskInstances.value = 0;
secondaryValueTasks.value = 0;
};
onMounted(async () => {
const routeParams = route.params;
const primaryType = routeParams.primaryType;
const secondaryType = routeParams.secondaryType;
const primaryId = routeParams.primaryId;
const secondaryId = routeParams.secondaryId;
const primaryData = await compareStore.getStateData(primaryType, primaryId);
const secondaryData = await compareStore.getStateData(
secondaryType,
secondaryId,
);
const primaryFileName = await compareStore.getFileName(primaryId);
const secondaryFileName = await compareStore.getFileName(secondaryId);
primaryStatData.value = await getStatData(primaryData, primaryFileName);
secondaryStatData.value = await getStatData(secondaryData, secondaryFileName);
});
</script>
<style scoped>
:deep(.p-progressbar .p-progressbar-value) {
background-color: var(--bg-color);
}
.progressbar-primary {
--bg-color: #0099ff;
}
.progressbar-secondary {
--bg-color: #ffaa44;
}
</style>

View File

@@ -1,995 +0,0 @@
<template>
<section
class="p-4 mr-0.5 space-y-2 h-full w-[calc(100vw_-_316px)] overflow-y-auto scrollbar float-right"
>
<div
v-show="isCoverPlate"
class="w-[calc(100vw_-_300px)] h-screen-main fixed bottom-0 right-0 bg-gradient-to-tr from-neutral-500/50 to-neutral-900/50 z-[1]"
></div>
<!-- title -->
<p class="text-base leading-10 font-bold">
Conformance Checking Results ({{ data.total }})
</p>
<!-- total group -->
<ul class="text-neutral-10 text-sm flex gap-2 py-2">
<li class="bg-cfm-primary rounded-full px-4 py-1 space-x-2">
<span class="material-symbols-outlined !text-base align-middle mr-2"
>check_circle</span
>Conforming<span>{{ data.counts.conforming }}</span>
</li>
<li class="bg-cfm-secondary rounded-full px-4 py-1 space-x-2">
<span class="material-symbols-outlined !text-base align-middle mr-2"
>cancel</span
>Not Conforming<span>{{ data.counts.not_conforming }}</span>
</li>
<li
class="bg-neutral-700 rounded-full px-4 py-1 space-x-2"
v-show="data.counts.not_applicable != 0"
>
<iconNA class="inline-block mr-1"></iconNA>Not Applicable<span>{{
data.counts.not_applicable
}}</span>
</li>
</ul>
<!-- chart -->
<div class="flex gap-4">
<div class="border rounded border-neutral-300 p-2 bg-neutral-10 w-1/2">
<div class="p-2 flex justify-between items-center">
<div>
<span class="block text-sm font-bold mb-2"
>Conformance Rate<span
class="material-symbols-outlined !text-sm align-middle ml-2 cursor-pointer"
v-tooltip.bottom="tooltip.rate"
>info</span
></span
>
<small class="text-neutral-700 font-normal block"
>{{ data.charts.rate.xMin }} ~ {{ data.charts.rate.xMax }}</small
>
</div>
<span class="text-2xl font-bold">{{ data.charts.rate.rate }}%</span>
</div>
<Chart
type="line"
:data="rateChartData"
:options="rateChartOptions"
class="w-[99%]"
/>
</div>
<div class="border rounded border-neutral-300 p-2 bg-neutral-10 w-1/2">
<div class="p-2 flex justify-between items-center">
<div>
<span class="block text-sm font-bold mb-2"
>Cases<span
class="material-symbols-outlined !text-sm align-middle ml-2 cursor-pointer"
v-tooltip.bottom="tooltip.case"
>info</span
></span
>
<small class="text-neutral-700 font-normal block"
>{{ data.charts.cases.xMin }} ~
{{ data.charts.cases.xMax }}</small
>
</div>
<span class="text-2xl font-bold"
><span class="text-cfm-primary">{{
data.charts.cases.conforming
}}</span
>&nbsp;/&nbsp;{{ data.charts.cases.total }}</span
>
</div>
<Chart
type="bar"
:data="casesChartData"
:options="casesChartOptions"
class="w-[99%]"
/>
</div>
</div>
<!-- effect -->
<section>
<p class="h2 text-base">Effect</p>
<div class="flex gap-4 w-full">
<div class="border rounded border-neutral-300 p-2 bg-neutral-10 w-1/2">
<p class="h2 pl-2 mb-2">Throughput Time</p>
<div v-if="data.effect.time !== null">
<p
class="pl-2 space-x-2"
v-if="data.effect.time.not_conforming === null"
>
<span
>All cases are conforming to set rules. Average throughput time
is</span
>
<span class="text-cfm-primary text-2xl font-medium">{{
data.effect.time.conforming
}}</span>
<span>days.</span>
</p>
<p
class="pl-2 space-x-2"
v-else-if="data.effect.time.conforming === null"
>
<span
>None of the cases is conforming to set rules. Average
throughput time is</span
>
<span class="text-cfm-secondary text-2xl font-medium">{{
data.effect.time.not_conforming
}}</span>
<span>days.</span>
</p>
<p class="pl-2 space-x-2 max-w-full" v-else>
<span
class="text-cfm-primary text-2xl font-medium inline-block"
>{{ data.effect.time.conforming }}</span
>
<span>vs</span>
<span
class="text-cfm-secondary text-2xl font-medium inline-block"
>{{ data.effect.time.not_conforming }}</span
>
<span>days,</span>
<span class="text-2xl font-medium inline-block">{{
data.effect.time.difference
}}</span>
<span>days of difference.</span>
</p>
</div>
</div>
<div class="border rounded border-neutral-300 p-2 bg-neutral-10 w-1/2">
<p class="h2 pl-2 mb-2">Activities per Case</p>
<div v-if="data.effect.tasks !== null">
<p
class="pl-2 space-x-2"
v-if="data.effect.tasks.not_conforming === null"
>
<span
>All cases are conforming to set rules. Average activities in
per cases is</span
>
<span class="text-cfm-primary text-2xl font-medium">{{
data.effect.tasks.conforming
}}</span>
.
</p>
<p
class="pl-2 space-x-2"
v-else-if="data.effect.tasks.conforming === null"
>
<span
>None of the cases is conforming to set rules. Average
activities in per cases is</span
>
<span class="text-cfm-secondary text-2xl font-medium">{{
data.effect.tasks.not_conforming
}}</span>
.
</p>
<p class="pl-2 space-x-2 max-w-full" v-else>
<span
class="text-cfm-primary text-2xl font-medium inline-block"
>{{ data.effect.tasks.conforming }}</span
>
<span>vs</span>
<span
class="text-cfm-secondary text-2xl font-medium inline-block"
>{{ data.effect.tasks.not_conforming }}</span
>
<span>activities,</span>
<span class="text-2xl font-medium inline-block">{{
data.effect.tasks.difference
}}</span>
<span>activities of difference.</span>
</p>
</div>
</div>
</div>
</section>
<!-- Loop group -->
<section>
<div v-if="data.loops === null"></div>
<div v-else>
<p class="h2 text-base">Loop List</p>
<div class="border rounded border-neutral-300 p-2 bg-neutral-10 w-full">
<p class="h2 pl-2 mb-2">Short Loop(s)</p>
<table class="text-sm min-w-full table-fixed">
<caption class="hidden">
Loop List
</caption>
<thead class="hidden">
<tr>
<th class="w-1/5 px-4 py-2 hidden"></th>
<th class="w-1/5 px-4 py-2 hidden"></th>
<th class="w-1/5 px-4 py-2 hidden"></th>
<th class="w-1/5 px-4 py-2 hidden"></th>
<th class="w-1/5 px-4 py-2 hidden"></th>
</tr>
</thead>
<tbody>
<tr v-for="(trace, key) in data.loops" :key="key">
<td class="p-2 pl-6 truncate max-w-0 w-1/3">
<span
class="material-symbols-outlined disc !text-sm align-middle mr-1"
>fiber_manual_record</span
>{{ trace.label }}
</td>
<td class="p-2 min-w-[96px] w-2/5">
<div
class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden"
>
<div class="h-full bg-primary" :style="trace.value"></div>
</div>
</td>
<td class="p-2 text-right truncate">{{ trace.count }}</td>
<td class="p-2 text-right">{{ trace.ratio }}%</td>
<td class="p-2 text-center">
<div
class="btn btn-sm btn-c-primary cursor-pointer"
@click="openLoopMore(trace.no)"
>
More
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Issues group -->
<section>
<div v-if="data.issues === 'reset'">
<p class="h2 text-base">Non-conformance Issues</p>
<div class="border rounded border-neutral-300 p-2 bg-neutral-10 w-full">
<p class="h2 pl-2 mb-2">Issue List</p>
</div>
</div>
<div v-else>
<div>
<div
v-if="
(data.issues?.length === 0 || data.issues === null) &&
data.timeTrend.chart === null
"
></div>
<p v-else class="h2 text-base">Non-conformance Issues</p>
</div>
<div
class="flex w-full"
:class="
data.issues === null || data.issues?.length === 0 ? '' : 'gap-4'
"
>
<!-- Issues chart -->
<div
v-if="data.timeTrend.chart !== null"
class="border rounded border-neutral-300 p-2 bg-neutral-10"
:class="
data.issues === null || data.issues?.length === 0
? 'w-full'
: 'w-1/2'
"
>
<p class="h2 p-2 flex justify-between items-center">
<span
>Time Trend<span
class="material-symbols-outlined !text-sm align-middle ml-2"
v-tooltip.bottom="tooltip.timeTrend"
>info</span
></span
>
<span class="text-2xl"
><span class="text-cfm-secondary">{{
data.timeTrend.not_conforming
}}</span
>&nbsp;/&nbsp;{{ data.timeTrend.total }}</span
>
</p>
<Chart
type="line"
:data="timeChartData"
:options="timeChartOptions"
class="w-[99%]"
/>
</div>
<!-- Issues list -->
<div v-if="data.issues === null || data.issues?.length === 0"></div>
<div
v-else
class="border rounded border-neutral-300 p-2 bg-neutral-10"
:class="data.timeTrend.chart !== null ? 'w-1/2' : 'w-full'"
>
<p class="h2 pl-2 mb-2">
Issue List<span
class="material-symbols-outlined !text-sm align-middle ml-2 cursor-pointer"
v-tooltip.bottom="tooltip.issueList"
>info</span
>
</p>
<table class="text-sm min-w-full table-fixed">
<caption class="hidden">
Issues List
</caption>
<thead class="hidden">
<tr>
<th class="w-1/5 px-4 py-2 hidden"></th>
<th class="w-1/5 px-4 py-2 hidden"></th>
<th class="w-1/5 px-4 py-2 hidden"></th>
<th class="w-1/5 px-4 py-2 hidden"></th>
<th class="w-1/5 px-4 py-2 hidden"></th>
</tr>
</thead>
<tbody>
<tr v-for="(trace, key) in data.issues" :key="key">
<td class="p-2 pl-6 truncate max-w-0 w-1/3">
<span
class="material-symbols-outlined disc !text-sm align-middle mr-1"
>fiber_manual_record</span
>{{ trace.label }}
</td>
<td class="p-2 min-w-[96px] w-2/5">
<div
class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden"
>
<div
class="h-full bg-cfm-secondary"
:style="trace.value"
></div>
</div>
</td>
<td class="p-2 text-right truncate">{{ trace.count }}</td>
<td class="p-2 text-right">{{ trace.ratio }}%</td>
<td class="p-2 text-center">
<div
class="btn btn-sm btn-cfm-secondary cursor-pointer"
@click="openMore(trace.no)"
>
More
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<MoreModal
:listModal="issuesModal"
@closeModal="issuesModal = $event"
:listTraces="issueTraces"
:taskSeq="taskSeq"
:cases="cases"
:listNo="issuesNo"
:traceId="traceId"
:firstCases="firstCases"
:category="'issue'"
></MoreModal>
<MoreModal
:listModal="loopModal"
@closeModal="loopModal = $event"
:listTraces="loopTraces"
:taskSeq="loopTaskSeq"
:cases="loopCases"
:listNo="loopNo"
:traceId="looptraceId"
:firstCases="loopFirstCases"
:category="'loop'"
></MoreModal>
</section>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceResults
* Conformance checking results panel displaying rule
* check outcomes in a data table with status indicators.
*/
import { ref, watch, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useConformanceStore } from "@/stores/conformance";
import iconNA from "@/components/icons/IconNA.vue";
import MoreModal from "./MoreModal.vue";
import getNumberLabel from "@/module/numberLabel.js";
import {
setLineChartData,
setBarChartData,
timeRange,
yTimeRange,
getXIndex,
formatTime,
formatMaxTwo,
} from "@/module/setChartData.js";
import shortScaleNumber from "@/module/shortScaleNumber.js";
import getMoment from "moment";
import emitter from "@/utils/emitter";
const conformanceStore = useConformanceStore();
const {
conformanceTempReportData,
issueTraces,
taskSeq,
cases,
loopTraces,
loopTaskSeq,
loopCases,
} = storeToRefs(conformanceStore);
const data = ref({
total: "--",
counts: {
conforming: "--",
not_conforming: "--",
not_applicable: 0,
},
charts: {
rate: {
rate: "--",
chart: {},
},
cases: {
conforming: "--",
total: "--",
chart: {},
},
fitness: "--",
},
effect: {
time: null,
tasks: null,
},
loops: null,
issues: "reset",
timeTrend: {
not_conforming: "--",
total: "--",
chart: {},
},
});
const isCoverPlate = ref(false);
const issuesModal = ref(false);
const loopModal = ref(false);
const rateChartData = ref(null);
const rateChartOptions = ref(null);
const casesChartData = ref(null);
const casesChartOptions = ref(null);
const timeChartData = ref(null);
const timeChartOptions = ref(null);
const issuesNo = ref(null);
const traceId = ref(null);
const firstCases = ref(null);
const loopNo = ref(null);
const looptraceId = ref(null);
const loopFirstCases = ref(null);
const selectDurationTime = ref(null);
const tooltip = ref({
rate: {
value: "= Conforming / (Conforming + Not Conforming) * 100%",
class: "!max-w-[36rem] !text-[10px] !opacity-90",
},
case: {
value: "= Conforming / (Conforming + Not Conforming)",
class: "!max-w-[36rem] !text-[10px] !opacity-90",
},
timeTrend: {
value: "=Not Conforming / (total Conforming+total Not Conforming)",
class: "!max-w-[36rem] !text-[10px] !opacity-90",
},
issueList: {
value:
"Percentage of Issue Type (%) = Cases of Issue Type / Total Cases of All Issue Types.",
class: "!max-w-[36rem] !text-[10px] !opacity-90",
},
});
/**
* set progress bar width
* @param {number} value - The percentage value.
* @returns {string} The CSS width style string.
*/
const progressWidth = (value) => {
return `width:${value}%;`;
};
/**
* Number to percentage
* @param {number} val - The raw ratio value.
* @returns {string} The formatted percentage string.
*/
const getPercentLabel = (val) => {
if (Number((val * 100).toFixed(1)) >= 100) return 100;
else return Number.parseFloat((val * 100).toFixed(1));
};
/**
* Convert seconds to days
* @param {number} sec - The number of seconds.
* @returns {number} day
*/
const convertSecToDay = (sec) => {
return sec / 86400;
};
/**
* Open Issues Modal.
* @param {number} no - The trace number.
*/
const openMore = async (no) => {
// Use async/await to prevent errors caused by asynchronous data not being available yet
issuesNo.value = no;
await conformanceStore.getConformanceIssue(no);
if (issueTraces.value.length === 0) return;
traceId.value = issueTraces.value[0].id;
firstCases.value = await conformanceStore.getConformanceTraceDetail(
no,
issueTraces.value[0].id,
0,
);
issuesModal.value = true;
};
/**
* Open Loop Modal.
* @param {number} no - The trace number.
*/
const openLoopMore = async (no) => {
// Use async/await to prevent errors caused by asynchronous data not being available yet
loopNo.value = no;
await conformanceStore.getConformanceLoop(no);
if (loopTraces.value.length === 0) return;
looptraceId.value = loopTraces.value[0].id;
loopFirstCases.value = await conformanceStore.getConformanceLoopsTraceDetail(
no,
loopTraces.value[0].id,
0,
);
loopModal.value = true;
};
/**
* set conformance report data
* @param {object} data - The report data received from the backend.
*/
const setConformanceTempReportData = (newData) => {
const total = getNumberLabel(
Object.values(newData.counts).reduce((acc, val) => acc + val, 0),
);
const sum = newData.counts.conforming + newData.counts.not_conforming;
const rate = sum === 0 ? "0.0" : ((newData.counts.conforming / sum) * 100).toFixed(1);
const isNullTime = (value) =>
value === null ? null : getNumberLabel((value / 86400).toFixed(1));
const isNullCase = (value) =>
value === null ? null : getNumberLabel(value.toFixed(1));
const setLoopData = (value) => {
if (newData.counts.conforming === 0) return [];
return value.map((item) => {
return {
no: item.no,
label: item.description,
value: `width:${getPercentLabel(item.count / newData.counts.conforming)}%;`,
count: getNumberLabel(item.count),
ratio: getPercentLabel(item.count / newData.counts.conforming),
};
});
};
const setIssueData = (value) => {
if (newData.counts.not_conforming === 0) return [];
return value.map((item) => {
return {
no: item.no,
label: item.description,
value: `width:${getPercentLabel(item.count / newData.counts.not_conforming)}%;`,
count: getNumberLabel(item.count),
ratio: getPercentLabel(item.count / newData.counts.not_conforming),
};
});
};
const isNullLoops = (value) => (value === null ? null : setLoopData(value));
const isNullIsssue = (value) => (value === null ? null : setIssueData(value));
const result = {
total: `Total ${total}`,
counts: {
conforming: getNumberLabel(newData.counts.conforming),
not_conforming: getNumberLabel(newData.counts.not_conforming),
not_applicable: getNumberLabel(newData.counts.not_applicable),
},
charts: {
rate: {
rate: rate,
data: setLineChartData(
newData.charts.rate.data,
newData.charts.rate.x_axis.max,
newData.charts.rate.x_axis.min,
true,
),
xMax: getMoment(newData.charts.rate.x_axis.max).format("YYYY/M/D"),
xMin: getMoment(newData.charts.rate.x_axis.min).format("YYYY/M/D"),
},
cases: {
conforming: getNumberLabel(newData.counts.conforming),
total: getNumberLabel(sum),
data: {
conforming: setBarChartData(
newData.charts.cases.data
.filter((item) => item.label === "conforming")
.map((item) => item.data)[0],
),
not_conforming: setBarChartData(
newData.charts.cases.data
.filter((item) => item.label === "not-conforming")
.map((item) => item.data)[0],
),
},
xMax: getMoment(newData.charts.cases.x_axis.max).format("YYYY/M/D"),
xMin: getMoment(newData.charts.cases.x_axis.min).format("YYYY/M/D"),
},
fitness: getNumberLabel(newData.charts.fitness),
},
effect: {
time: {
conforming: isNullTime(newData.effect.time.conforming),
not_conforming: isNullTime(newData.effect.time.not_conforming),
difference: (
isNullTime(newData.effect.time.conforming) -
isNullTime(newData.effect.time.not_conforming)
).toFixed(1),
},
tasks: {
conforming: isNullCase(newData.effect.tasks.conforming),
not_conforming: isNullCase(newData.effect.tasks.not_conforming),
difference: (
isNullCase(newData.effect.tasks.conforming) -
isNullCase(newData.effect.tasks.not_conforming)
).toFixed(1),
},
},
loops: isNullLoops(newData.loops),
issues: isNullIsssue(newData.issues),
timeTrend: {
not_conforming: getNumberLabel(newData.counts.not_conforming),
total: getNumberLabel(sum),
chart: null,
xMax: null,
xMin: null,
yMax: null,
yMin: null,
},
};
if (newData.charts.time) {
result.timeTrend.chart = setLineChartData(
newData.charts.time.data,
newData.charts.time.x_axis.max,
newData.charts.time.x_axis.min,
false,
newData.charts.time.y_axis.max,
newData.charts.time.y_axis.min,
);
result.timeTrend.xMax = newData.charts.time.x_axis.max;
result.timeTrend.xMin = newData.charts.time.x_axis.min;
result.timeTrend.yMax = newData.charts.time.y_axis.max;
result.timeTrend.yMin = newData.charts.time.y_axis.min;
}
setRateChartData(result.charts.rate.data); // Build the Rate Chart.js chart
setCasesChartData(
result.charts.cases.data.conforming,
result.charts.cases.data.not_conforming,
newData.charts.cases.x_axis.max,
newData.charts.cases.x_axis.min,
); // Build the Cases Chart.js chart
if (newData.charts.time)
setTimeChartData(
result.timeTrend.chart,
result.timeTrend.xMax,
result.timeTrend.xMin,
result.timeTrend.yMax,
result.timeTrend.yMin,
); // Build the Time Chart.js chart
return result;
};
/**
* set Rate Chart Data
* @param {object} data new rate chart data
*/
const setRateChartData = (chartData) => {
rateChartData.value = {
labels: [],
datasets: [
{
label: "Rate",
data: chartData,
fill: false,
pointRadius: 0, // Hide data points
pointHoverRadius: 0, // Hide data points on hover
tension: 0.4,
borderColor: "#0099FF",
x: "x",
y: "y",
},
],
};
rateChartOptions.value = {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 0.6,
layout: {
padding: {
top: 16,
left: 8,
right: 8,
},
},
plugins: {
legend: false, // Hide legend
tooltip: {
enabled: false, // Hide tooltip
},
},
scales: {
x: {
type: "time",
ticks: {
display: false,
},
grid: {
display: false, // Hide x-axis grid lines
},
border: {
color: "#334155",
},
},
y: {
beginAtZero: true, // Scale includes 0
suggestedMin: 0,
suggestedMax: 1,
ticks: {
// Set tick intervals
includeBounds: true,
color: "#334155",
align: "inner",
callback: function (value, index, values) {
if (value === 0 || value === 1) {
return `${value * 100}%`;
}
},
},
grid: {
display: false, // Hide y-axis grid lines
},
border: {
color: "#334155",
},
},
},
};
};
/**
* set Cases Chart Data
* @param {array} conformingData new cases chart conforming data
* @param {array} notConformingData new cases chart not conforming data
* @param {number} xMax new cases chart xMax
* @param {number} xMin new cases chart xMin
*/
const setCasesChartData = (conformingData, notConformingData, xMax, xMin) => {
casesChartData.value = {
datasets: [
{
type: "bar",
label: "Conforming",
data: conformingData,
backgroundColor: "#0099FF",
},
{
type: "bar",
label: "Not Conforming",
data: notConformingData,
backgroundColor: "#FFAA44",
},
],
};
casesChartOptions.value = {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 0.8,
layout: {
padding: {
top: 16,
left: 8,
right: 8,
},
},
plugins: {
tooltips: {
mode: "index",
intersect: false,
},
legend: false, // Hide legend
},
scales: {
x: {
stacked: true,
ticks: {
display: false,
},
grid: {
display: false, // Hide x-axis grid lines
},
border: {
color: "#334155",
},
},
y: {
stacked: true,
beginAtZero: true, // Scale includes 0
ticks: {
color: "#334155",
align: "inner",
callback: function (value, index, values) {
if (index === 0 || index === values.length - 1) {
return shortScaleNumber(value);
}
},
},
grid: {
// display: false, // Hide y-axis grid lines
color: function (context) {
return context.tick.value === 0 ? "#334155" : null;
},
drawTicks: false,
},
border: {
color: "#334155",
},
},
},
};
};
/**
* set Time Trend chart data
* @param {array} chartData Time Trend chart conforming data
* @param {number} xMax Time Trend xMax
* @param {number} xMin Time Trend xMin
* @param {number} yMax Time Trend yMax
* @param {number} yMin Time Trend yMin
*/
const setTimeChartData = (chartData, xMax, xMin, yMax, yMin) => {
const max = yMax * 1.1;
const xVal = timeRange(xMin, xMax, 100);
const yVal = yTimeRange(chartData, 100, yMin, yMax);
xVal.map((x, index) => ({ x, y: yVal[index] }));
let formattedXVal = xVal.map((value) => formatTime(value));
formattedXVal = formatMaxTwo(formattedXVal);
const selectTimeMinIndex = getXIndex(xVal, selectDurationTime.value.min);
const selectTimeMaxIndex = getXIndex(xVal, selectDurationTime.value.max);
const start = selectTimeMinIndex;
const end = selectTimeMaxIndex;
const inside = (ctx, value) =>
ctx.p0DataIndex >= start && ctx.p1DataIndex <= end ? value : undefined;
const outside = (ctx, value) =>
ctx.p0DataIndex < start || ctx.p1DataIndex > end ? value : undefined;
timeChartData.value = {
labels: formattedXVal,
datasets: [
{
label: "Conforming",
data: yVal,
fill: true,
showLine: false,
tension: 0.4,
backgroundColor: "rgba(0,153,255)",
pointRadius: 0,
pointHitRadius: 0,
spanGaps: true,
segment: {
backgroundColor: (ctx) =>
inside(ctx, "rgb(0,153,255)") || outside(ctx, "rgb(255,170,68)"),
},
x: "x",
y: "y",
},
],
};
timeChartOptions.value = {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 16,
left: 8,
right: 8,
},
},
plugins: {
legend: false, // Hide legend
tooltip: false,
},
scales: {
x: {
ticks: {
maxRotation: 0, // Do not rotate labels (0~50)
color: "#334155",
display: true,
},
grid: {
display: false, // Hide x-axis grid lines
},
title: {
display: true,
text: "Time",
color: "rgba(100,116,139)",
},
},
y: {
beginAtZero: true, // Scale includes 0
max: max,
ticks: {
// Set tick intervals
display: false, // Hide values, only show grid lines
stepSize: max / 4,
},
grid: {
color: "rgba(100,116,139)",
drawTicks: false, // Hide extra space on the left
},
border: {
display: false, // Hide the extra border line on the left
},
title: {
display: true,
text: "Occurrences",
color: "rgba(100,116,139)",
},
},
},
};
};
// watch
watch(conformanceTempReportData, (newValue) => {
if (newValue?.rule && newValue.rule.min !== null) {
selectDurationTime.value = {
min: newValue.rule.min,
max: newValue.rule.max,
};
}
data.value = setConformanceTempReportData(newValue);
});
// created - emitter listeners
emitter.on("coverPlate", (boolean) => {
isCoverPlate.value = boolean;
});
// Get selectTimeRange for use by Time Trend
emitter.on(
"timeRangeMaxMin",
(newData) => (selectDurationTime.value = newData),
);
onBeforeUnmount(() => {
emitter.off("coverPlate");
emitter.off("timeRangeMaxMin");
});
</script>
<style scoped>
:deep(.disc) {
font-variation-settings:
"FILL" 1,
"wght" 100,
"GRAD" 0,
"opsz" 20;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +0,0 @@
<template>
<div
class="h-full bg-neutral-10 border border-neutral-300 rounded-xl ml-4 p-4 space-y-2"
>
<p class="h2 pl-2 border-b mb-3">Activity list</p>
<div
class="flex flex-wrap justify-start content-start gap-4 px-2 overflow-y-auto scrollbar h-[calc(100%_-_48px)]"
id="cyp-conformance-list-checkbox"
>
<div
class="flex items-center w-[166px]"
v-for="(act, index) in sortData"
:key="index"
:title="act"
>
<Checkbox
v-model="actList"
:inputId="index.toString()"
name="actList"
:value="act"
@change="actListData"
/>
<label
:for="index"
class="ml-2 p-2 whitespace-nowrap break-keep text-ellipsis overflow-hidden"
>{{ act }}</label
>
</div>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ActList
* Checkbox-based activity list for conformance checking input.
*/
import { ref, watch, onBeforeUnmount } from "vue";
import { sortNumEngZhtw } from "@/module/sortNumEngZhtw.js";
import emitter from "@/utils/emitter";
const props = defineProps(["data", "select"]);
const sortData = ref([]);
const actList = ref(props.select);
watch(
() => props.data,
(newValue) => {
sortData.value = sortNumEngZhtw(newValue);
},
{ immediate: true },
);
watch(
() => props.select,
(newValue) => {
actList.value = newValue;
},
);
/** Emits the selected activities list via the event bus. */
function actListData() {
emitter.emit("actListData", actList.value);
}
// created
emitter.on("reset", (data) => {
actList.value = data;
});
onBeforeUnmount(() => {
emitter.off("reset");
});
</script>

View File

@@ -1,116 +0,0 @@
<template>
<div
class="h-full bg-neutral-10 border border-neutral-300 rounded-xl ml-4 p-4 space-y-2"
>
<p class="h2 pl-2 border-b mb-3">{{ title }}</p>
<div
class="flex flex-wrap justify-start content-start gap-4 px-2 overflow-y-auto scrollbar h-[calc(100%_-_48px)]"
>
<div
class="flex items-center w-[166px]"
v-for="(act, index) in sortData"
:key="index"
:title="act"
>
<RadioButton
v-model="selectedRadio"
:inputId="index + act"
:name="select"
:value="act"
@change="actRadioData"
/>
<label
:for="index + act"
class="ml-2 p-2 whitespace-nowrap break-keep text-ellipsis overflow-hidden"
>{{ act }}</label
>
</div>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ActRadio
* Radio-button activity selector for conformance checking
* start/end activity input.
*/
import { ref, computed, watch, onBeforeUnmount } from "vue";
import { useConformanceInputStore } from "@/stores/conformanceInput";
import { sortNumEngZhtw } from "@/module/sortNumEngZhtw.js";
import emitter from "@/utils/emitter";
const props = defineProps([
"title",
"select",
"data",
"category",
"task",
"isSubmit",
]);
const emit = defineEmits(["selected-task"]);
const conformanceInputStore = useConformanceInputStore();
const sortData = ref([]);
const localSelect = ref(null);
const selectedRadio = ref(null);
watch(
() => props.data,
(newValue) => {
sortData.value = sortNumEngZhtw(newValue);
},
{ immediate: true },
);
watch(
() => props.task,
(newValue) => {
selectedRadio.value = newValue;
},
);
const inputActivityRadioData = computed(() => ({
category: props.category,
task: selectedRadio.value,
}));
/** Emits the selected activity via event bus and updates the store. */
function actRadioData() {
localSelect.value = null;
emitter.emit("actRadioData", inputActivityRadioData.value);
emit("selected-task", selectedRadio.value);
conformanceInputStore.setActivityRadioStartEndData(
inputActivityRadioData.value.task,
);
}
/** Sets the global activity radio data state in the conformance input store. */
function setGlobalActivityRadioDataState() {
//this.title: value might be "From" or "To"
conformanceInputStore.setActivityRadioStartEndData(
inputActivityRadioData.value.task,
props.title,
);
}
// created
sortNumEngZhtw(sortData.value);
localSelect.value = props.isSubmit ? props.select : null;
selectedRadio.value = localSelect.value;
emitter.on("reset", (data) => {
selectedRadio.value = data;
});
setGlobalActivityRadioDataState();
onBeforeUnmount(() => {
emitter.off("reset");
});
</script>

View File

@@ -1,217 +0,0 @@
<template>
<div class="h-full w-full flex justify-between items-center">
<!-- Activity List -->
<div
class="h-full w-full bg-neutral-10 border border-neutral-300 rounded-xl ml-4 p-4 space-y-2"
>
<p class="h2 pl-2 border-b mb-3">Activity list</p>
<div class="h-[calc(100%_-_56px)]">
<Draggable
:list="datadata"
:group="{ name: 'activity', pull: 'clone' }"
itemKey="name"
animation="300"
:fallbackTolerance="5"
:forceFallback="true"
:ghostClass="'ghostSelected'"
:dragClass="'dragSelected'"
:sort="false"
@end="onEnd"
class="h-full flex flex-wrap justify-start content-start gap-4 px-2 overflow-y-auto scrollbar"
>
<template #item="{ element, index }">
<div
:class="
listSequence.includes(element)
? 'border-primary text-primary'
: ''
"
class="flex items-center w-[166px] border rounded p-2 bg-neutral-10 cursor-pointer hover:bg-primary/20"
@dblclick="moveActItem(index, element)"
:title="element"
>
<span
class="whitespace-nowrap break-keep text-ellipsis overflow-hidden"
>{{ element }}</span
>
</div>
</template>
</Draggable>
</div>
</div>
<!-- sequence -->
<div
class="w-full h-full relative bg-neutral-10 border border-neutral-300 rounded-xl ml-4 p-4 space-y-2 text-sm"
>
<p class="h2 border-b border-500 mb-3">Sequence</p>
<!-- No Data -->
<div
v-if="listSequence && listSequence.length === 0"
class="p-4 w-[calc(100%_-_32px)] h-5/6 flex justify-center items-center absolute"
>
<p class="text-neutral-500">
Please drag and drop at least two activities here and sort.
</p>
</div>
<!-- Have Data -->
<div class="m-auto w-full h-[calc(100%_-_56px)]">
<div
class="w-full h-full overflow-y-auto overflow-x-auto scrollbar px-4 text-center"
>
<draggable
class="h-full"
:group="{ name: 'activity' }"
:list="listSequence"
itemKey="name"
animation="300"
:forceFallback="true"
:dragClass="'dragSelected'"
:fallbackTolerance="5"
@start="onStart"
@end="onEnd"
:component-data="getComponentData()"
:ghostClass="'!opacity-0'"
>
<template #item="{ element, index }">
<div :title="element">
<div class="flex justify-center items-center">
<div
class="w-full p-2 border rounded bg-neutral-10 cursor-pointer hover:bg-primary/20"
@dblclick="moveSeqItem(index, element)"
>
<span>{{ element }}</span>
</div>
<span
class="material-symbols-outlined pl-1 cursor-pointer duration-300 hover:text-danger"
@click.stop="moveSeqItem(index, element)"
>close</span
>
</div>
<span
v-show="
index !== listSequence.length - 1 &&
index !== lastItemIndex - 1
"
class="pi pi-chevron-down !text-lg inline-block py-2 pr-7"
></span>
</div>
</template>
</draggable>
</div>
</div>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ActSeqDrag
* Drag-and-drop activity sequence builder for
* conformance rule configuration.
*/
import { ref, computed, onBeforeUnmount } from "vue";
import { sortNumEngZhtw } from "@/module/sortNumEngZhtw.js";
import emitter from "@/utils/emitter";
import { cloneDeep } from "lodash-es";
const props = defineProps(["data", "listSeq", "isSubmit", "category"]);
const listSequence = ref([]);
const lastItemIndex = ref(null);
const isSelect = ref(true);
const datadata = computed(() => {
// Sort the Activity List
let newData;
if (props.data !== null) {
newData = cloneDeep(props.data);
sortNumEngZhtw(newData);
}
return newData;
});
/**
* double click Activity List
* @param {number} index data item index
* @param {object} element data item
*/
function moveActItem(index, element) {
listSequence.value.push(element);
}
/**
* double click Sequence List
* @param {number} index data item index
* @param {object} element data item
*/
function moveSeqItem(index, element) {
listSequence.value.splice(index, 1);
}
/**
* get listSequence
*/
function getComponentData() {
emitter.emit("getListSequence", {
category: props.category,
task: listSequence.value,
});
}
/**
* Element dragging started
*/
function onStart(evt) {
const lastChild = evt.to.lastChild.lastChild;
lastChild.style.display = "none";
// Hide the dragged element at its original position
const originalElement = evt.item;
originalElement.style.display = "none";
// When dragging the last element, hide the arrow of the second-to-last element
const listIndex = listSequence.value.length - 1;
if (evt.oldIndex === listIndex) lastItemIndex.value = listIndex;
}
/**
* Element dragging ended
*/
function onEnd(evt) {
// Show the dragged element
const originalElement = evt.item;
originalElement.style.display = "";
// Show the arrow after drag ends, except for the last element
const lastChild = evt.item.lastChild;
const listIndex = listSequence.value.length - 1;
if (evt.oldIndex !== listIndex) {
lastChild.style.display = "";
}
// Reset: hide the second-to-last element's arrow when dragging the last element
lastItemIndex.value = null;
}
// created
const newlist = cloneDeep(props.listSeq);
listSequence.value = props.isSubmit ? newlist : [];
emitter.on("reset", (data) => {
listSequence.value = [];
});
onBeforeUnmount(() => {
emitter.off("reset");
});
</script>
<style scoped>
@reference "../../../../assets/tailwind.css";
.ghostSelected {
@apply bg-primary/20;
}
.dragSelected {
@apply !opacity-100;
}
</style>

View File

@@ -1,205 +0,0 @@
<template>
<section class="space-y-2 text-sm">
<!-- Rule Type -->
<div id="cyp-conformance-type-radio">
<p class="h2">Rule Type</p>
<div v-for="rule in ruleType" :key="rule.id" class="ml-4 mb-2">
<RadioButton
v-model="selectedRuleType"
:inputId="rule.id + rule.name"
name="ruleType"
:value="rule.name"
@change="changeRadio"
/>
<label :for="rule.id + rule.name" class="ml-2">{{ rule.name }}</label>
</div>
</div>
<!-- Activity Sequence (2 item) -->
<div
v-show="selectedRuleType === 'Activity sequence'"
id="cyp-conformance-sequence-radio"
>
<p class="h2">Activity Sequence</p>
<div v-for="act in activitySequence" :key="act.id" class="ml-4 mb-2">
<RadioButton
v-model="selectedActivitySequence"
:inputId="act.id + act.name"
name="activitySequence"
:value="act.name"
@change="changeRadioSeq"
/>
<label :for="act.id + act.name" class="ml-2">{{ act.name }}</label>
</div>
</div>
<!-- Mode -->
<div
v-show="
selectedRuleType === 'Activity sequence' &&
selectedActivitySequence === 'Sequence'
"
id="cyp-conformance-Mode-radio"
>
<p class="h2">Mode</p>
<div v-for="mode in mode" :key="mode.id" class="ml-4 mb-2">
<RadioButton
v-model="selectedMode"
:inputId="mode.id + mode.name"
name="mode"
:value="mode.name"
/>
<label :for="mode.id + mode.name" class="ml-2">{{ mode.name }}</label>
</div>
</div>
<!-- Process Scope -->
<div
v-show="
selectedRuleType === 'Processing time' ||
selectedRuleType === 'Waiting time'
"
id="cyp-conformance-procss-radio"
>
<p class="h2">Process Scope</p>
<div v-for="pro in processScope" :key="pro.id" class="ml-4 mb-2">
<RadioButton
v-model="selectedProcessScope"
:inputId="pro.id + pro.name"
name="processScope"
:value="pro.name"
@change="changeRadioProcessScope"
/>
<label :for="pro.id + pro.name" class="ml-2">{{ pro.name }}</label>
</div>
</div>
<!-- Activity Sequence (4 item) -->
<div
v-show="
(selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end') ||
(selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end') ||
selectedRuleType === 'Cycle time'
"
id="cyp-conformance-actseq-radio"
>
<p class="h2">Activity Sequence</p>
<div v-for="act in actSeqMore" :key="act.id" class="ml-4 mb-2">
<RadioButton
v-model="selectedActSeqMore"
:inputId="act.id + act.name"
name="activitySequenceMore"
:value="act.name"
@change="changeRadioActSeqMore"
/>
<label :for="act.id + act.name" class="ml-2">{{ act.name }}</label>
</div>
</div>
<!-- Activity Sequence (3 item) -->
<div
v-show="
(selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial') ||
(selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial')
"
id="cyp-conformance-actseqfromto-radio"
>
<p class="h2">Activity Sequence</p>
<div v-for="act in actSeqFromTo" :key="act.id" class="ml-4 mb-2">
<RadioButton
v-model="selectedActSeqFromTo"
:inputId="act.id + act.name"
name="activitySequenceFromTo"
:value="act.name"
@change="changeRadioActSeqFromTo"
/>
<label :for="act.id + act.name" class="ml-2">{{ act.name }}</label>
</div>
</div>
</section>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ConformanceRadioGroup
* Radio button groups for conformance rule type, activity
* sequence, mode, and process scope selection.
*/
import { storeToRefs } from "pinia";
import { useConformanceStore } from "@/stores/conformance";
import emitter from "@/utils/emitter";
const conformanceStore = useConformanceStore();
const {
selectedRuleType,
selectedActivitySequence,
selectedMode,
selectedProcessScope,
selectedActSeqMore,
selectedActSeqFromTo,
} = storeToRefs(conformanceStore);
const ruleType = [
{ id: 1, name: "Have activity" },
{ id: 2, name: "Activity sequence" },
{ id: 3, name: "Activity duration" },
{ id: 4, name: "Processing time" },
{ id: 5, name: "Waiting time" },
{ id: 6, name: "Cycle time" },
];
const activitySequence = [
{ id: 1, name: "Start & End" },
{ id: 2, name: "Sequence" },
];
const mode = [
{ id: 1, name: "Directly follows" },
{ id: 2, name: "Eventually follows" },
{ id: 3, name: "Short loop(s)" },
{ id: 4, name: "Self loop(s)" },
];
const processScope = [
{ id: 1, name: "End to end" },
{ id: 2, name: "Partial" },
];
const actSeqMore = [
{ id: 1, name: "All" },
{ id: 2, name: "Start" },
{ id: 3, name: "End" },
{ id: 4, name: "Start & End" },
];
const actSeqFromTo = [
{ id: 1, name: "From" },
{ id: 2, name: "To" },
{ id: 3, name: "From & To" },
];
/** Resets dependent selections when the rule type radio changes. */
function changeRadio() {
selectedActivitySequence.value = "Start & End";
selectedMode.value = "Directly follows";
selectedProcessScope.value = "End to end";
selectedActSeqMore.value = "All";
selectedActSeqFromTo.value = "From";
emitter.emit("isRadioChange", true); // Clear data when switching radio buttons
}
/** Emits event when the activity sequence radio changes. */
function changeRadioSeq() {
emitter.emit("isRadioSeqChange", true);
}
/** Emits event when the process scope radio changes. */
function changeRadioProcessScope() {
emitter.emit("isRadioProcessScopeChange", true);
}
/** Emits event when the extended activity sequence radio changes. */
function changeRadioActSeqMore() {
emitter.emit("isRadioActSeqMoreChange", true);
}
/** Emits event when the from/to activity sequence radio changes. */
function changeRadioActSeqFromTo() {
emitter.emit("isRadioActSeqFromToChange", true);
}
</script>

View File

@@ -1,473 +0,0 @@
<template>
<div class="px-4 text-sm">
<!-- Have activity -->
<ResultCheck
v-if="selectedRuleType === 'Have activity'"
:data="state.containstTasksData"
:select="isSubmitTask"
></ResultCheck>
<!-- Activity sequence -->
<ResultDot
v-if="
selectedRuleType === 'Activity sequence' &&
selectedActivitySequence === 'Start & End'
"
:timeResultData="selectCfmSeqSE"
:select="isSubmitStartAndEnd"
></ResultDot>
<ResultArrow
v-if="
selectedRuleType === 'Activity sequence' &&
selectedActivitySequence === 'Sequence' &&
selectedMode === 'Directly follows'
"
:data="state.selectCfmSeqDirectly"
:select="isSubmitCfmSeqDirectly"
></ResultArrow>
<ResultArrow
v-if="
selectedRuleType === 'Activity sequence' &&
selectedActivitySequence === 'Sequence' &&
selectedMode === 'Eventually follows'
"
:data="state.selectCfmSeqEventually"
:select="isSubmitCfmSeqEventually"
></ResultArrow>
<!-- Activity duration -->
<ResultCheck
v-if="selectedRuleType === 'Activity duration'"
:title="'Activities include'"
:data="state.durationData"
:select="isSubmitDurationData"
></ResultCheck>
<!-- Processing time -->
<ResultDot
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start'
"
:timeResultData="state.selectCfmPtEteStart"
:select="isSubmitCfmPtEteStart"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'End'
"
:timeResultData="state.selectCfmPtEteEnd"
:select="isSubmitCfmPtEteEnd"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start & End'
"
:timeResultData="selectCfmPtEteSE"
:select="isSubmitCfmPtEteSE"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From'
"
:timeResultData="state.selectCfmPtPStart"
:select="isSubmitCfmPtPStart"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'To'
"
:timeResultData="state.selectCfmPtPEnd"
:select="isSubmitCfmPtPEnd"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From & To'
"
:timeResultData="selectCfmPtPSE"
:select="isSubmitCfmPtPSE"
></ResultDot>
<!-- Waiting time -->
<ResultDot
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start'
"
:timeResultData="state.selectCfmWtEteStart"
:select="isSubmitCfmWtEteStart"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'End'
"
:timeResultData="state.selectCfmWtEteEnd"
:select="isSubmitCfmWtEteEnd"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start & End'
"
:timeResultData="selectCfmWtEteSE"
:select="isSubmitCfmWtEteSE"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From'
"
:timeResultData="state.selectCfmWtPStart"
:select="isSubmitCfmWtPStart"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'To'
"
:timeResultData="state.selectCfmWtPEnd"
:select="isSubmitCfmWtPEnd"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From & To'
"
:timeResultData="selectCfmWtPSE"
:select="isSubmitCfmWtPSE"
></ResultDot>
<!-- Cycle time -->
<ResultDot
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start'
"
:timeResultData="state.selectCfmCtEteStart"
:select="isSubmitCfmCtEteStart"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'End'
"
:timeResultData="state.selectCfmCtEteEnd"
:select="isSubmitCfmCtEteEnd"
></ResultDot>
<ResultDot
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start & End'
"
:timeResultData="selectCfmCtEteSE"
:select="isSubmitCfmCtEteSE"
></ResultDot>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ConformanceSelectResult
* Conformance result list with selectable items and
* scrollable display of check results.
*/
import { reactive, computed, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useConformanceStore } from "@/stores/conformance";
import emitter from "@/utils/emitter";
import ResultCheck from "@/components/Discover/Conformance/ConformanceSidebar/ResultCheck.vue";
import ResultArrow from "@/components/Discover/Conformance/ConformanceSidebar/ResultArrow.vue";
import ResultDot from "@/components/Discover/Conformance/ConformanceSidebar/ResultDot.vue";
import { cloneDeep } from "lodash-es";
const conformanceStore = useConformanceStore();
const {
selectedRuleType,
selectedActivitySequence,
selectedMode,
selectedProcessScope,
selectedActSeqMore,
selectedActSeqFromTo,
isStartSelected,
isEndSelected,
} = storeToRefs(conformanceStore);
const props = defineProps([
"isSubmit",
"isSubmitTask",
"isSubmitStartAndEnd",
"isSubmitCfmSeqDirectly",
"isSubmitCfmSeqEventually",
"isSubmitDurationData",
"isSubmitCfmPtEteStart",
"isSubmitCfmPtEteEnd",
"isSubmitCfmPtEteSE",
"isSubmitCfmPtPStart",
"isSubmitCfmPtPEnd",
"isSubmitCfmPtPSE",
"isSubmitCfmWtEteStart",
"isSubmitCfmWtEteEnd",
"isSubmitCfmWtEteSE",
"isSubmitCfmWtPStart",
"isSubmitCfmWtPEnd",
"isSubmitCfmWtPSE",
"isSubmitCfmCtEteStart",
"isSubmitCfmCtEteEnd",
"isSubmitCfmCtEteSE",
]);
const state = reactive({
containstTasksData: null,
startEndData: null,
selectCfmSeqStart: null,
selectCfmSeqEnd: null,
selectCfmSeqDirectly: [],
selectCfmSeqEventually: [],
durationData: null,
selectCfmPtEteStart: null, // Processing time
selectCfmPtEteEnd: null,
selectCfmPtEteSEStart: null,
selectCfmPtEteSEEnd: null,
selectCfmPtPStart: null,
selectCfmPtPEnd: null,
selectCfmPtPSEStart: null,
selectCfmPtPSEEnd: null,
selectCfmWtEteStart: null, // Waiting time
selectCfmWtEteEnd: null,
selectCfmWtEteSEStart: null,
selectCfmWtEteSEEnd: null,
selectCfmWtPStart: null,
selectCfmWtPEnd: null,
selectCfmWtPSEStart: null,
selectCfmWtPSEEnd: null,
selectCfmCtEteStart: null, // Cycle time
selectCfmCtEteEnd: null,
selectCfmCtEteSEStart: null,
selectCfmCtEteSEEnd: null,
startAndEndIsReset: false,
});
const selectCfmSeqSE = computed(() => {
const data = [];
if (state.selectCfmSeqStart) data.push(state.selectCfmSeqStart);
if (state.selectCfmSeqEnd) data.push(state.selectCfmSeqEnd);
data.sort((a, b) => {
const order = { Start: 1, End: 2 };
return order[a.category] - order[b.category];
});
return data;
});
const selectCfmPtEteSE = computed(() => {
const data = [];
if (state.selectCfmPtEteSEStart) data.push(state.selectCfmPtEteSEStart);
if (state.selectCfmPtEteSEEnd) data.push(state.selectCfmPtEteSEEnd);
data.sort((a, b) => {
const order = { Start: 1, End: 2 };
return order[a.category] - order[b.category];
});
return data;
});
const selectCfmPtPSE = computed(() => {
const data = [];
if (state.selectCfmPtPSEStart) data.push(state.selectCfmPtPSEStart);
if (state.selectCfmPtPSEEnd) data.push(state.selectCfmPtPSEEnd);
data.sort((a, b) => {
const order = { From: 1, To: 2 };
return order[a.category] - order[b.category];
});
return data;
});
const selectCfmWtEteSE = computed(() => {
const data = [];
if (state.selectCfmWtEteSEStart) data.push(state.selectCfmWtEteSEStart);
if (state.selectCfmWtEteSEEnd) data.push(state.selectCfmWtEteSEEnd);
data.sort((a, b) => {
const order = { Start: 1, End: 2 };
return order[a.category] - order[b.category];
});
return data;
});
const selectCfmWtPSE = computed(() => {
const data = [];
if (state.selectCfmWtPSEStart) data.push(state.selectCfmWtPSEStart);
if (state.selectCfmWtPSEEnd) data.push(state.selectCfmWtPSEEnd);
data.sort((a, b) => {
const order = { From: 1, To: 2 };
return order[a.category] - order[b.category];
});
return data;
});
const selectCfmCtEteSE = computed(() => {
const data = [];
if (state.selectCfmCtEteSEStart) data.push(state.selectCfmCtEteSEStart);
if (state.selectCfmCtEteSEEnd) data.push(state.selectCfmCtEteSEEnd);
data.sort((a, b) => {
const order = { Start: 1, End: 2 };
return order[a.category] - order[b.category];
});
return data;
});
/**
* All reset
*/
function reset() {
state.containstTasksData = null;
state.startEndData = null;
state.selectCfmSeqStart = null;
state.selectCfmSeqEnd = null;
state.selectCfmSeqDirectly = [];
state.selectCfmSeqEventually = [];
state.durationData = null;
state.selectCfmPtEteStart = null;
state.selectCfmPtEteEnd = null;
state.selectCfmPtEteSEStart = null;
state.selectCfmPtEteSEEnd = null;
state.selectCfmPtPStart = null;
state.selectCfmPtPEnd = null;
state.selectCfmPtPSEStart = null;
state.selectCfmPtPSEEnd = null;
state.selectCfmWtEteStart = null; // Waiting time
state.selectCfmWtEteEnd = null;
state.selectCfmWtEteSEStart = null;
state.selectCfmWtEteSEEnd = null;
state.selectCfmWtPStart = null;
state.selectCfmWtPEnd = null;
state.selectCfmWtPSEStart = null;
state.selectCfmWtPSEEnd = null;
state.selectCfmCtEteStart = null; // Cycle time
state.selectCfmCtEteEnd = null;
state.selectCfmCtEteSEStart = null;
state.selectCfmCtEteSEEnd = null;
state.startAndEndIsReset = true;
}
// created() logic
emitter.on("actListData", (data) => {
state.containstTasksData = data;
});
emitter.on("actRadioData", (newData) => {
const data = cloneDeep(newData);
const categoryMapping = {
cfmSeqStart: ["Start", "selectCfmSeqStart", "selectCfmSeqEnd"],
cfmSeqEnd: ["End", "selectCfmSeqEnd", "selectCfmSeqStart"],
cfmPtEteStart: ["Start", "selectCfmPtEteStart"],
cfmPtEteEnd: ["End", "selectCfmPtEteEnd"],
cfmPtEteSEStart: ["Start", "selectCfmPtEteSEStart", "selectCfmPtEteSEEnd"],
cfmPtEteSEEnd: ["End", "selectCfmPtEteSEEnd", "selectCfmPtEteSEStart"],
cfmPtPStart: ["From", "selectCfmPtPStart"],
cfmPtPEnd: ["To", "selectCfmPtPEnd"],
cfmPtPSEStart: ["From", "selectCfmPtPSEStart", "selectCfmPtPSEEnd"],
cfmPtPSEEnd: ["To", "selectCfmPtPSEEnd", "selectCfmPtPSEStart"],
cfmWtEteStart: ["Start", "selectCfmWtEteStart"],
cfmWtEteEnd: ["End", "selectCfmWtEteEnd"],
cfmWtEteSEStart: ["Start", "selectCfmWtEteSEStart", "selectCfmWtEteSEEnd"],
cfmWtEteSEEnd: ["End", "selectCfmWtEteSEEnd", "selectCfmWtEteSEStart"],
cfmWtPStart: ["From", "selectCfmWtPStart"],
cfmWtPEnd: ["To", "selectCfmWtPEnd"],
cfmWtPSEStart: ["From", "selectCfmWtPSEStart", "selectCfmWtPSEEnd"],
cfmWtPSEEnd: ["To", "selectCfmWtPSEEnd", "selectCfmWtPSEStart"],
cfmCtEteStart: ["Start", "selectCfmCtEteStart"],
cfmCtEteEnd: ["End", "selectCfmCtEteEnd"],
cfmCtEteSEStart: ["Start", "selectCfmCtEteSEStart", "selectCfmCtEteSEEnd"],
cfmCtEteSEEnd: ["End", "selectCfmCtEteSEEnd", "selectCfmCtEteSEStart"],
};
const updateSelection = (key, mainSelector, secondarySelector) => {
if (state[mainSelector]) {
if (data.task !== state[mainSelector]) state[secondarySelector] = null;
}
data.category = categoryMapping[key][0];
state[mainSelector] = data;
};
if (categoryMapping[data.category]) {
const [category, mainSelector, secondarySelector] =
categoryMapping[data.category];
if (secondarySelector) {
updateSelection(data.category, mainSelector, secondarySelector);
} else {
data.category = category;
state[mainSelector] = [data];
}
} else if (selectedRuleType.value === "Activity duration") {
state.durationData = [data.task];
}
});
emitter.on("getListSequence", (data) => {
switch (data.category) {
case "cfmSeqDirectly":
state.selectCfmSeqDirectly = data.task;
break;
case "cfmSeqEventually":
state.selectCfmSeqEventually = data.task;
break;
default:
break;
}
});
emitter.on("reset", (data) => {
reset();
});
// Clear data when switching radio buttons
emitter.on("isRadioChange", (data) => {
if (data) reset();
});
emitter.on("isRadioProcessScopeChange", (data) => {
if (data) reset();
});
emitter.on("isRadioActSeqMoreChange", (data) => {
if (data) reset();
});
emitter.on("isRadioActSeqFromToChange", (data) => {
if (data) reset();
});
onBeforeUnmount(() => {
emitter.off("actListData");
emitter.off("actRadioData");
emitter.off("getListSequence");
emitter.off("reset");
emitter.off("isRadioChange");
emitter.off("isRadioProcessScopeChange");
emitter.off("isRadioActSeqMoreChange");
emitter.off("isRadioActSeqFromToChange");
});
</script>
<style scoped>
:deep(.disc) {
font-variation-settings:
"FILL" 1,
"wght" 100,
"GRAD" 0,
"opsz" 20;
}
</style>

View File

@@ -1,759 +0,0 @@
<template>
<section class="animate-fadein w-full h-full">
<!-- Have activity -->
<ActList
v-if="selectedRuleType === 'Have activity'"
:data="conformanceTask"
:select="isSubmitTask"
></ActList>
<!-- Activity sequence -->
<div
v-if="
selectedRuleType === 'Activity sequence' &&
selectedActivitySequence === 'Start & End'
"
class="flex justify-between items-center w-full h-full"
>
<ActRadio
:title="'Start activity'"
:select="isSubmitStartAndEnd?.[0].task"
:data="cfmSeqStartData"
:category="'cfmSeqStart'"
:task="taskStart"
:isSubmit="isSubmit"
@selected-task="selectStart"
class="w-1/2"
/>
<ActRadio
:title="'End activity'"
:select="isSubmitStartAndEnd?.[1].task"
:data="cfmSeqEndData"
:category="'cfmSeqEnd'"
:task="taskEnd"
:isSubmit="isSubmit"
@selected-task="selectEnd"
class="w-1/2"
/>
</div>
<!-- actSeqDrag -->
<ActSeqDrag
v-if="
selectedRuleType === 'Activity sequence' &&
selectedActivitySequence === 'Sequence' &&
selectedMode === 'Directly follows'
"
:data="conformanceTask"
:listSeq="isSubmitCfmSeqDirectly"
:isSubmit="isSubmit"
:category="'cfmSeqDirectly'"
></ActSeqDrag>
<ActSeqDrag
v-if="
selectedRuleType === 'Activity sequence' &&
selectedActivitySequence === 'Sequence' &&
selectedMode === 'Eventually follows'
"
:data="conformanceTask"
:listSeq="isSubmitCfmSeqEventually"
:isSubmit="isSubmit"
:category="'cfmSeqEventually'"
></ActSeqDrag>
<!-- Activity duration -->
<ActRadio
v-if="selectedRuleType === 'Activity duration'"
:title="'Activities include'"
:select="isSubmitDurationData?.[0]"
:data="conformanceTask"
:category="'cfmDur'"
:isSubmit="isSubmit"
/>
<!-- Processing time -->
<ActRadio
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start'
"
:title="'Start'"
:select="isSubmitCfmPtEteStart?.[0].task"
:data="cfmPtEteStartData"
:category="'cfmPtEteStart'"
:isSubmit="isSubmit"
/>
<ActRadio
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'End'
"
:title="'End'"
:select="isSubmitCfmPtEteEnd?.[0].task"
:data="cfmPtEteEndData"
:category="'cfmPtEteEnd'"
:isSubmit="isSubmit"
/>
<div
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start & End'
"
class="flex justify-between items-center w-full h-full"
>
<ActRadio
:title="'Start'"
:select="isSubmitCfmPtEteSE?.[0].task"
:data="cfmPtEteSEStartData"
:category="'cfmPtEteSEStart'"
:task="taskStart"
:isSubmit="isSubmit"
@selected-task="selectStart"
class="w-1/2"
/>
<ActRadio
:title="'End'"
:select="isSubmitCfmPtEteSE?.[1].task"
:data="cfmPtEteSEEndData"
:category="'cfmPtEteSEEnd'"
:task="taskEnd"
:isSubmit="isSubmit"
@selected-task="selectEnd"
class="w-1/2"
/>
</div>
<ActRadio
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From'
"
:title="'From'"
:select="isSubmitCfmPtPStart?.[0].task"
:data="cfmPtPStartData"
:category="'cfmPtPStart'"
:isSubmit="isSubmit"
/>
<ActRadio
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'To'
"
:title="'To'"
:select="isSubmitCfmPtPEnd?.[0].task"
:data="cfmPtPEndData"
:category="'cfmPtPEnd'"
:isSubmit="isSubmit"
/>
<div
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From & To'
"
class="flex justify-between items-center w-full h-full"
>
<ActRadio
:title="'From'"
:select="isSubmitCfmPtPSE?.[0].task"
:data="cfmPtPSEStartData"
class="w-1/2"
:category="'cfmPtPSEStart'"
:task="taskStart"
:isSubmit="isSubmit"
@selected-task="selectStart"
/>
<ActRadio
:title="'To'"
:select="isSubmitCfmPtPSE?.[1].task"
:data="cfmPtPSEEndData"
class="w-1/2"
:category="'cfmPtPSEEnd'"
:task="taskEnd"
:isSubmit="isSubmit"
@selected-task="selectEnd"
/>
</div>
<!-- Waiting time -->
<ActRadio
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start'
"
:title="'Start'"
:select="isSubmitCfmWtEteStart?.[0].task"
:data="cfmWtEteStartData"
:category="'cfmWtEteStart'"
:isSubmit="isSubmit"
/>
<ActRadio
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'End'
"
:title="'End'"
:select="isSubmitCfmWtEteEnd?.[0].task"
:data="cfmWtEteEndData"
:category="'cfmWtEteEnd'"
:isSubmit="isSubmit"
/>
<div
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start & End'
"
class="flex justify-between items-center w-full h-full"
>
<ActRadio
:title="'Start'"
:select="isSubmitCfmWtEteSE?.[0].task"
:data="cfmWtEteSEStartData"
class="w-1/2"
:category="'cfmWtEteSEStart'"
:task="taskStart"
:isSubmit="isSubmit"
@selected-task="selectStart"
/>
<ActRadio
:title="'End'"
:select="isSubmitCfmWtEteSE?.[1].task"
:data="cfmWtEteSEEndData"
class="w-1/2"
:category="'cfmWtEteSEEnd'"
:task="taskEnd"
:isSubmit="isSubmit"
@selected-task="selectEnd"
/>
</div>
<ActRadio
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From'
"
:title="'From'"
:select="isSubmitCfmWtPStart?.[0].task"
:data="cfmWtPStartData"
:category="'cfmWtPStart'"
:isSubmit="isSubmit"
/>
<ActRadio
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'To'
"
:title="'To'"
:select="isSubmitCfmWtPEnd?.[0].task"
:data="cfmWtPEndData"
:category="'cfmWtPEnd'"
:isSubmit="isSubmit"
/>
<div
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From & To'
"
class="flex justify-between items-center w-full h-full"
>
<ActRadio
:title="'From'"
:select="isSubmitCfmWtPSE?.[0].task"
:data="cfmWtPSEStartData"
class="w-1/2"
:category="'cfmWtPSEStart'"
:task="taskStart"
:isSubmit="isSubmit"
@selected-task="selectStart"
/>
<ActRadio
:title="'To'"
:select="isSubmitCfmWtPSE?.[1].task"
:data="cfmWtPSEEndData"
class="w-1/2"
:category="'cfmWtPSEEnd'"
:task="taskEnd"
:isSubmit="isSubmit"
@selected-task="selectEnd"
/>
</div>
<!-- Cycle time -->
<ActRadio
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start'
"
:title="'Start'"
:select="isSubmitCfmCtEteStart?.[0].task"
:data="cfmCtEteStartData"
:category="'cfmCtEteStart'"
:isSubmit="isSubmit"
/>
<ActRadio
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'End'
"
:title="'End'"
:select="isSubmitCfmCtEteEnd?.[0].task"
:data="cfmCtEteEndData"
:category="'cfmCtEteEnd'"
:isSubmit="isSubmit"
/>
<div
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start & End'
"
class="flex justify-between items-center w-full h-full"
>
<ActRadio
:title="'Start'"
:select="isSubmitCfmCtEteSE?.[0].task"
:data="cfmCtEteSEStartData"
class="w-1/2"
:category="'cfmCtEteSEStart'"
:task="taskStart"
:isSubmit="isSubmit"
@selected-task="selectStart"
/>
<ActRadio
:title="'End'"
:select="isSubmitCfmCtEteSE?.[1].task"
:data="cfmCtEteSEEndData"
class="w-1/2"
:category="'cfmCtEteSEEnd'"
:task="taskEnd"
:isSubmit="isSubmit"
@selected-task="selectEnd"
/>
</div>
</section>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ConformanceShowBar
* Horizontal bar chart component displaying conformance
* check result statistics.
*/
import { ref, computed, watch, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useLoadingStore } from "@/stores/loading";
import { useConformanceStore } from "@/stores/conformance";
import emitter from "@/utils/emitter";
import ActList from "./ActList.vue";
import ActRadio from "./ActRadio.vue";
import ActSeqDrag from "./ActSeqDrag.vue";
const loadingStore = useLoadingStore();
const conformanceStore = useConformanceStore();
const { isLoading } = storeToRefs(loadingStore);
const {
selectedRuleType,
selectedActivitySequence,
selectedMode,
selectedProcessScope,
selectedActSeqMore,
selectedActSeqFromTo,
conformanceTask,
cfmSeqStart,
cfmSeqEnd,
cfmPtEteStart,
cfmPtEteEnd,
cfmPtEteSE,
cfmPtPStart,
cfmPtPEnd,
cfmPtPSE,
cfmWtEteStart,
cfmWtEteEnd,
cfmWtEteSE,
cfmWtPStart,
cfmWtPEnd,
cfmWtPSE,
cfmCtEteStart,
cfmCtEteEnd,
cfmCtEteSE,
isStartSelected,
isEndSelected,
} = storeToRefs(conformanceStore);
const props = defineProps([
"isSubmit",
"isSubmitTask",
"isSubmitStartAndEnd",
"isSubmitCfmSeqDirectly",
"isSubmitCfmSeqEventually",
"isSubmitDurationData",
"isSubmitCfmPtEteStart",
"isSubmitCfmPtEteEnd",
"isSubmitCfmPtEteSE",
"isSubmitCfmPtPStart",
"isSubmitCfmPtPEnd",
"isSubmitCfmPtPSE",
"isSubmitCfmWtEteStart",
"isSubmitCfmWtEteEnd",
"isSubmitCfmWtEteSE",
"isSubmitCfmWtPStart",
"isSubmitCfmWtPEnd",
"isSubmitCfmWtPSE",
"isSubmitCfmCtEteStart",
"isSubmitCfmCtEteEnd",
"isSubmitCfmCtEteSE",
"isSubmitShowDataSeq",
"isSubmitShowDataPtEte",
"isSubmitShowDataPtP",
"isSubmitShowDataWtEte",
"isSubmitShowDataWtP",
"isSubmitShowDataCt",
]);
const task = ref(null);
const taskStart = ref(null);
const taskEnd = ref(null);
// Activity sequence
const cfmSeqStartData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataSeq?.task ?? null;
return isEndSelected.value
? setSeqStartAndEndData(cfmSeqEnd.value, "sources", selectedTask)
: cfmSeqStart.value.map((i) => i.label);
});
const cfmSeqEndData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataSeq?.task ?? null;
return isStartSelected.value
? setSeqStartAndEndData(cfmSeqStart.value, "sinks", selectedTask)
: cfmSeqEnd.value.map((i) => i.label);
});
// Processing time
const cfmPtEteStartData = computed(() => {
return cfmPtEteStart.value.map((i) => i.task);
});
const cfmPtEteEndData = computed(() => {
return cfmPtEteEnd.value.map((i) => i.task);
});
const cfmPtEteSEStartData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataPtEte?.task ?? null;
return isEndSelected.value
? setStartAndEndData(cfmPtEteSE.value, "end", selectedTask)
: setTaskData(cfmPtEteSE.value, "start");
});
const cfmPtEteSEEndData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataPtEte?.task ?? null;
return isStartSelected.value
? setStartAndEndData(cfmPtEteSE.value, "start", selectedTask)
: setTaskData(cfmPtEteSE.value, "end");
});
const cfmPtPStartData = computed(() => {
return cfmPtPStart.value.map((i) => i.task);
});
const cfmPtPEndData = computed(() => {
return cfmPtPEnd.value.map((i) => i.task);
});
const cfmPtPSEStartData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataPtP?.task ?? null;
return isEndSelected.value
? setStartAndEndData(cfmPtPSE.value, "end", selectedTask)
: setTaskData(cfmPtPSE.value, "start");
});
const cfmPtPSEEndData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataPtP?.task ?? null;
return isStartSelected.value
? setStartAndEndData(cfmPtPSE.value, "start", selectedTask)
: setTaskData(cfmPtPSE.value, "end");
});
// Waiting time
const cfmWtEteStartData = computed(() => {
return cfmWtEteStart.value.map((i) => i.task);
});
const cfmWtEteEndData = computed(() => {
return cfmWtEteEnd.value.map((i) => i.task);
});
const cfmWtEteSEStartData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataWtEte?.task ?? null;
return isEndSelected.value
? setStartAndEndData(cfmWtEteSE.value, "end", selectedTask)
: setTaskData(cfmWtEteSE.value, "start");
});
const cfmWtEteSEEndData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataWtEte?.task ?? null;
return isStartSelected.value
? setStartAndEndData(cfmWtEteSE.value, "start", selectedTask)
: setTaskData(cfmWtEteSE.value, "end");
});
const cfmWtPStartData = computed(() => {
return cfmWtPStart.value.map((i) => i.task);
});
const cfmWtPEndData = computed(() => {
return cfmWtPEnd.value.map((i) => i.task);
});
const cfmWtPSEStartData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataWtP?.task ?? null;
return isEndSelected.value
? setStartAndEndData(cfmWtPSE.value, "end", selectedTask)
: setTaskData(cfmWtPSE.value, "start");
});
const cfmWtPSEEndData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataWtP?.task ?? null;
return isStartSelected.value
? setStartAndEndData(cfmWtPSE.value, "start", selectedTask)
: setTaskData(cfmWtPSE.value, "end");
});
// Cycle time
const cfmCtEteStartData = computed(() => {
return cfmCtEteStart.value.map((i) => i.task);
});
const cfmCtEteEndData = computed(() => {
return cfmCtEteEnd.value.map((i) => i.task);
});
const cfmCtEteSEStartData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataCt?.task ?? null;
return isEndSelected.value
? setStartAndEndData(cfmCtEteSE.value, "end", selectedTask)
: setTaskData(cfmCtEteSE.value, "start");
});
const cfmCtEteSEEndData = computed(() => {
const selectedTask = task.value ?? props.isSubmitShowDataCt?.task ?? null;
return isStartSelected.value
? setStartAndEndData(cfmCtEteSE.value, "start", selectedTask)
: setTaskData(cfmCtEteSE.value, "end");
});
// Watchers - Fix issue where saved rule files could not be re-edited
watch(
() => props.isSubmitShowDataSeq,
(newValue) => {
taskStart.value = newValue.taskStart;
taskEnd.value = newValue.taskEnd;
},
);
watch(
() => props.isSubmitShowDataPtEte,
(newValue) => {
taskStart.value = newValue.taskStart;
taskEnd.value = newValue.taskEnd;
},
);
watch(
() => props.isSubmitShowDataPtP,
(newValue) => {
taskStart.value = newValue.taskStart;
taskEnd.value = newValue.taskEnd;
},
);
watch(
() => props.isSubmitShowDataWtEte,
(newValue) => {
taskStart.value = newValue.taskStart;
taskEnd.value = newValue.taskEnd;
},
);
watch(
() => props.isSubmitShowDataWtP,
(newValue) => {
taskStart.value = newValue.taskStart;
taskEnd.value = newValue.taskEnd;
},
);
watch(
() => props.isSubmitShowDataCt,
(newValue) => {
taskStart.value = newValue.taskStart;
taskEnd.value = newValue.taskEnd;
},
);
/**
* Sets the start and end radio data.
* @param {object} data - Activities list data from the backend (cfmSeqStart, cfmSeqEnd, cfmPtEteSE, etc.).
* @param {string} category - 'start' or 'end'.
* @returns {array}
*/
function setTaskData(data, category) {
let newData = data.map((i) => i[category]);
newData = [...new Set(newData)]; // Set is a collection type that only stores unique values.
return newData;
}
/**
* Resets the linked start and end radio data.
* @param {object} data - Activities list data from the backend (cfmPtEteSE, cfmPtPSE, etc.).
* @param {string} category - 'start' or 'end'.
* @param {string} task - The selected activity task.
* @returns {array}
*/
function setStartAndEndData(data, category, taskVal) {
let oppositeCategory = "";
if (category === "start") {
oppositeCategory = "end";
} else {
oppositeCategory = "start";
}
let newData = data
.filter((i) => i[category] === taskVal)
.map((i) => i[oppositeCategory]);
newData = [...new Set(newData)];
return newData;
}
/**
* Resets the activity sequence linked start and end radio data.
* @param {object} data - Activities list data from the backend (cfmSeqStart or cfmSeqEnd).
* @param {string} category - 'sources' or 'sinks'.
* @param {string} task - The selected activity task.
* @returns {array}
*/
function setSeqStartAndEndData(data, category, taskVal) {
let newData = data.filter((i) => i.label === taskVal).map((i) => i[category]);
newData = [...new Set(...newData)];
return newData;
}
/**
* select start list's task
* @param {Event} e - The input event.
*/
function selectStart(e) {
taskStart.value = e;
if (isStartSelected.value === null || isStartSelected.value === true) {
isStartSelected.value = true;
isEndSelected.value = false;
task.value = e;
taskEnd.value = null;
}
}
/**
* select End list's task
* @param {Event} e - The input event.
*/
function selectEnd(e) {
taskEnd.value = e;
if (isEndSelected.value === null || isEndSelected.value === true) {
isEndSelected.value = true;
isStartSelected.value = false;
task.value = e;
taskStart.value = null;
}
}
/**
* reset all data.
*/
function reset() {
task.value = null;
isStartSelected.value = null;
isEndSelected.value = null;
taskStart.value = null;
taskEnd.value = null;
}
/**
* Updates linked Start & End data when radio selection changes.
* @param {boolean} data - Whether data should be restored from submission state.
*/
function setResetData(data) {
if (data) {
if (props.isSubmit) {
switch (selectedRuleType.value) {
case "Activity sequence":
task.value = props.isSubmitShowDataSeq.task;
isStartSelected.value = props.isSubmitShowDataSeq.isStartSelected;
isEndSelected.value = props.isSubmitShowDataSeq.isEndSelected;
break;
case "Processing time":
switch (selectedProcessScope.value) {
case "End to end":
task.value = props.isSubmitShowDataPtEte.task;
isStartSelected.value =
props.isSubmitShowDataPtEte.isStartSelected;
isEndSelected.value = props.isSubmitShowDataPtEte.isEndSelected;
break;
case "Partial":
task.value = props.isSubmitShowDataPtP.task;
isStartSelected.value = props.isSubmitShowDataPtP.isStartSelected;
isEndSelected.value = props.isSubmitShowDataPtP.isEndSelected;
break;
default:
break;
}
break;
case "Waiting time":
switch (selectedProcessScope.value) {
case "End to end":
task.value = props.isSubmitShowDataWtEte.task;
isStartSelected.value =
props.isSubmitShowDataWtEte.isStartSelected;
isEndSelected.value = props.isSubmitShowDataWtEte.isEndSelected;
break;
case "Partial":
task.value = props.isSubmitShowDataWtP.task;
isStartSelected.value = props.isSubmitShowDataWtP.isStartSelected;
isEndSelected.value = props.isSubmitShowDataWtP.isEndSelected;
break;
default:
break;
}
break;
case "Cycle time":
task.value = props.isSubmitShowDataCt.task;
isStartSelected.value = props.isSubmitShowDataCt.isStartSelected;
isEndSelected.value = props.isSubmitShowDataCt.isEndSelected;
break;
default:
break;
}
} else {
reset();
}
}
}
// created() logic
emitter.on("isRadioChange", (data) => {
setResetData(data);
});
emitter.on("isRadioSeqChange", (data) => {
setResetData(data);
});
emitter.on("isRadioProcessScopeChange", (data) => {
if (data) {
setResetData(data);
}
});
emitter.on("isRadioActSeqMoreChange", (data) => {
if (data) {
setResetData(data);
}
});
emitter.on("isRadioActSeqFromToChange", (data) => {
if (data) {
setResetData(data);
}
});
emitter.on("reset", (data) => {
reset();
});
onBeforeUnmount(() => {
emitter.off("isRadioChange");
emitter.off("isRadioSeqChange");
emitter.off("isRadioProcessScopeChange");
emitter.off("isRadioActSeqMoreChange");
emitter.off("isRadioActSeqFromToChange");
emitter.off("reset");
});
</script>

View File

@@ -1,673 +0,0 @@
<template>
<div
class="mt-2 mb-12"
v-if="
selectedRuleType === 'Activity duration' ||
selectedRuleType === 'Waiting time' ||
selectedRuleType === 'Processing time' ||
selectedRuleType === 'Cycle time'
"
>
<p class="h2">Time Range</p>
<div class="text-sm leading-normal">
<!-- Activity duration -->
<TimeRangeDuration
v-if="selectedRuleType === 'Activity duration'"
:time="state.timeDuration"
:select="isSubmitDurationTime"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<!-- Processing time -->
<TimeRangeDuration
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'All'
"
:time="state.timeCfmPtEteAll"
:select="isSubmitTimeCfmPtEteAll"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start'
"
:time="state.timeCfmPtEteStart"
:select="isSubmitTimeCfmPtEteStart"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'End'
"
:time="state.timeCfmPtEteEnd"
:select="isSubmitTimeCfmPtEteEnd"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start & End'
"
:time="state.timeCfmPtEteSE"
:select="isSubmitTimeCfmPtEteSE"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From'
"
:time="state.timeCfmPtPStart"
:select="isSubmitTimeCfmPtPStart"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'To'
"
:time="state.timeCfmPtPEnd"
:select="isSubmitTimeCfmPtPEnd"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Processing time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From & To'
"
:time="state.timeCfmPtPSE"
:select="isSubmitTimeCfmPtPSE"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<!-- Waiting time -->
<TimeRangeDuration
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'All'
"
:time="state.timeCfmWtEteAll"
:select="isSubmitTimeCfmWtEteAll"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start'
"
:time="state.timeCfmWtEteStart"
:select="isSubmitTimeCfmWtEteStart"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'End'
"
:time="state.timeCfmWtEteEnd"
:select="isSubmitTimeCfmWtEteEnd"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start & End'
"
:time="state.timeCfmWtEteSE"
:select="isSubmitTimeCfmWtEteSE"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From'
"
:time="state.timeCfmWtPStart"
:select="isSubmitTimeCfmWtPStart"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'To'
"
:time="state.timeCfmWtPEnd"
:select="isSubmitTimeCfmWtPEnd"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Waiting time' &&
selectedProcessScope === 'Partial' &&
selectedActSeqFromTo === 'From & To'
"
:time="state.timeCfmWtPSE"
:select="isSubmitTimeCfmWtPSE"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<!-- Cycle time -->
<TimeRangeDuration
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'All'
"
:time="state.timeCfmCtEteAll"
:select="isSubmitTimeCfmCtEteAll"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start'
"
:time="state.timeCfmCtEteStart"
:select="isSubmitTimeCfmCtEteStart"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'End'
"
:time="state.timeCfmCtEteEnd"
:select="isSubmitTimeCfmCtEteEnd"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
<TimeRangeDuration
v-if="
selectedRuleType === 'Cycle time' &&
selectedProcessScope === 'End to end' &&
selectedActSeqMore === 'Start & End'
"
:time="state.timeCfmCtEteSE"
:select="isSubmitTimeCfmCtEteSE"
@min-total-seconds="minTotalSeconds"
@max-total-seconds="maxTotalSeconds"
/>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ConformanceTimeRange
* Time range picker for conformance time-based rule
* configuration with calendar inputs.
*/
import { reactive, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useConformanceStore } from "@/stores/conformance";
import emitter from "@/utils/emitter";
import TimeRangeDuration from "@/components/Discover/Conformance/ConformanceSidebar/TimeRangeDuration.vue";
import { cloneDeep } from "lodash-es";
const conformanceStore = useConformanceStore();
const {
selectedRuleType,
selectedActivitySequence,
selectedMode,
selectedProcessScope,
selectedActSeqMore,
selectedActSeqFromTo,
conformanceAllTasks,
conformanceTask,
cfmSeqStart,
cfmSeqEnd,
cfmPtEteStart,
cfmPtEteEnd,
cfmPtEteSE,
cfmPtPStart,
cfmPtPEnd,
cfmPtPSE,
cfmWtEteStart,
cfmWtEteEnd,
cfmWtEteSE,
cfmWtPStart,
cfmWtPEnd,
cfmWtPSE,
cfmCtEteStart,
cfmCtEteEnd,
cfmCtEteSE,
cfmPtEteWhole,
cfmWtEteWhole,
cfmCtEteWhole,
} = storeToRefs(conformanceStore);
const props = defineProps([
"isSubmitDurationTime",
"isSubmitTimeCfmPtEteAll",
"isSubmitTimeCfmPtEteStart",
"isSubmitTimeCfmPtEteEnd",
"isSubmitTimeCfmPtEteSE",
"isSubmitTimeCfmPtPStart",
"isSubmitTimeCfmPtPEnd",
"isSubmitTimeCfmPtPSE",
"isSubmitTimeCfmWtEteAll",
"isSubmitTimeCfmWtEteStart",
"isSubmitTimeCfmWtEteEnd",
"isSubmitTimeCfmWtEteSE",
"isSubmitTimeCfmWtPStart",
"isSubmitTimeCfmWtPEnd",
"isSubmitTimeCfmWtPSE",
"isSubmitTimeCfmCtEteAll",
"isSubmitTimeCfmCtEteStart",
"isSubmitTimeCfmCtEteEnd",
"isSubmitTimeCfmCtEteSE",
]);
const emit = defineEmits(["min-total-seconds", "max-total-seconds"]);
const state = reactive({
timeDuration: null, // Activity duration
timeCfmPtEteAll: null, // Processing time
timeCfmPtEteAllDefault: null,
timeCfmPtEteStart: null,
timeCfmPtEteEnd: null,
timeCfmPtEteSE: null,
timeCfmPtPStart: null,
timeCfmPtPEnd: null,
timeCfmPtPSE: null,
timeCfmWtEteAll: null, // Waiting time
timeCfmWtEteAllDefault: null,
timeCfmWtEteStart: null,
timeCfmWtEteEnd: null,
timeCfmWtEteSE: null,
timeCfmWtPStart: null,
timeCfmWtPEnd: null,
timeCfmWtPSE: null,
timeCfmCtEteAll: null, // Cycle time
timeCfmCtEteAllDefault: null,
timeCfmCtEteStart: null,
timeCfmCtEteEnd: null,
timeCfmCtEteSE: null,
selectCfmPtEteSEStart: null,
selectCfmPtEteSEEnd: null,
selectCfmPtPSEStart: null,
selectCfmPtPSEEnd: null,
selectCfmWtEteSEStart: null,
selectCfmWtEteSEEnd: null,
selectCfmWtPSEStart: null,
selectCfmWtPSEEnd: null,
selectCfmCtEteSEStart: null,
selectCfmCtEteSEEnd: null,
});
// Store refs lookup for dynamic access in handleSingleSelection/handleDoubleSelection
const storeRefs = {
cfmPtEteStart,
cfmPtEteEnd,
cfmPtEteSE,
cfmPtPStart,
cfmPtPEnd,
cfmPtPSE,
cfmWtEteStart,
cfmWtEteEnd,
cfmWtEteSE,
cfmWtPStart,
cfmWtPEnd,
cfmWtPSE,
cfmCtEteStart,
cfmCtEteEnd,
cfmCtEteSE,
};
/**
* get min total seconds
* @param {number} e - The minimum total seconds.
*/
function minTotalSeconds(e) {
emit("min-total-seconds", e);
}
/**
* get min total seconds
* @param {number} e - The maximum total seconds.
*/
function maxTotalSeconds(e) {
emit("max-total-seconds", e);
}
/**
* get Time Range(duration)
* @param {Array} data - Activity list data from the API.
* @param {string} category - 'act', 'single', or 'double'.
* @param {string} task select Radio task or start
* @param {string} taskTwo end
* @returns {object} {min:12, max:345}
*/
function getDurationTime(data, category, task, taskTwo) {
let result = { min: 0, max: 0 };
switch (category) {
case "act":
data.forEach((i) => {
if (i.label === task) {
result = i.duration;
}
});
break;
case "single":
data.forEach((i) => {
if (i.task === task) {
result = i.time;
}
});
break;
case "double":
data.forEach((i) => {
if (i.start === task && i.end === taskTwo) {
result = i.time;
}
});
break;
case "all":
result = data;
break;
default:
break;
}
return result;
}
/**
* All reset
*/
function reset() {
state.timeDuration = null; // Activity duration
state.timeCfmPtEteAll = state.timeCfmPtEteAllDefault; // Processing time
state.timeCfmPtEteStart = null;
state.timeCfmPtEteEnd = null;
state.timeCfmPtEteSE = null;
state.timeCfmPtPStart = null;
state.timeCfmPtPEnd = null;
state.timeCfmPtPSE = null;
state.timeCfmWtEteAll = state.timeCfmWtEteAllDefault; // Waiting time
state.timeCfmWtEteStart = null;
state.timeCfmWtEteEnd = null;
state.timeCfmWtEteSE = null;
state.timeCfmWtPStart = null;
state.timeCfmWtPEnd = null;
state.timeCfmWtPSE = null;
state.timeCfmCtEteAll = state.timeCfmCtEteAllDefault; // Cycle time
state.timeCfmCtEteStart = null;
state.timeCfmCtEteEnd = null;
state.timeCfmCtEteSE = null;
state.selectCfmPtEteSEStart = null;
state.selectCfmPtEteSEEnd = null;
state.selectCfmPtPSEStart = null;
state.selectCfmPtPSEEnd = null;
state.selectCfmWtEteSEStart = null;
state.selectCfmWtEteSEEnd = null;
state.selectCfmWtPSEStart = null;
state.selectCfmWtPSEEnd = null;
state.selectCfmCtEteSEStart = null;
state.selectCfmCtEteSEEnd = null;
}
// created() logic
emitter.on("actRadioData", (data) => {
const category = data.category;
const task = data.task;
const handleDoubleSelection = (startKey, endKey, timeKey, durationType) => {
state[startKey] = task;
state[timeKey] = { min: 0, max: 0 };
if (state[endKey]) {
state[timeKey] = getDurationTime(
storeRefs[durationType].value,
"double",
task,
state[endKey],
);
}
};
const handleSingleSelection = (key, timeKey, durationType) => {
state[timeKey] = getDurationTime(
storeRefs[durationType].value,
"single",
task,
);
};
switch (category) {
// Activity duration
case "cfmDur":
state.timeDuration = getDurationTime(
conformanceAllTasks.value,
"act",
task,
);
break;
// Processing time
case "cfmPtEteStart":
handleSingleSelection(
"cfmPtEteStart",
"timeCfmPtEteStart",
"cfmPtEteStart",
);
break;
case "cfmPtEteEnd":
handleSingleSelection("cfmPtEteEnd", "timeCfmPtEteEnd", "cfmPtEteEnd");
break;
case "cfmPtEteSEStart":
handleDoubleSelection(
"selectCfmPtEteSEStart",
"selectCfmPtEteSEEnd",
"timeCfmPtEteSE",
"cfmPtEteSE",
);
break;
case "cfmPtEteSEEnd":
handleDoubleSelection(
"selectCfmPtEteSEEnd",
"selectCfmPtEteSEStart",
"timeCfmPtEteSE",
"cfmPtEteSE",
);
break;
case "cfmPtPStart":
handleSingleSelection("cfmPtPStart", "timeCfmPtPStart", "cfmPtPStart");
break;
case "cfmPtPEnd":
handleSingleSelection("cfmPtPEnd", "timeCfmPtPEnd", "cfmPtPEnd");
break;
case "cfmPtPSEStart":
handleDoubleSelection(
"selectCfmPtPSEStart",
"selectCfmPtPSEEnd",
"timeCfmPtPSE",
"cfmPtPSE",
);
break;
case "cfmPtPSEEnd":
handleDoubleSelection(
"selectCfmPtPSEEnd",
"selectCfmPtPSEStart",
"timeCfmPtPSE",
"cfmPtPSE",
);
break;
// Waiting time
case "cfmWtEteStart":
handleSingleSelection(
"cfmWtEteStart",
"timeCfmWtEteStart",
"cfmWtEteStart",
);
break;
case "cfmWtEteEnd":
handleSingleSelection("cfmWtEteEnd", "timeCfmWtEteEnd", "cfmWtEteEnd");
break;
case "cfmWtEteSEStart":
handleDoubleSelection(
"selectCfmWtEteSEStart",
"selectCfmWtEteSEEnd",
"timeCfmWtEteSE",
"cfmWtEteSE",
);
break;
case "cfmWtEteSEEnd":
handleDoubleSelection(
"selectCfmWtEteSEEnd",
"selectCfmWtEteSEStart",
"timeCfmWtEteSE",
"cfmWtEteSE",
);
break;
case "cfmWtPStart":
handleSingleSelection("cfmWtPStart", "timeCfmWtPStart", "cfmWtPStart");
break;
case "cfmWtPEnd":
handleSingleSelection("cfmWtPEnd", "timeCfmWtPEnd", "cfmWtPEnd");
break;
case "cfmWtPSEStart":
handleDoubleSelection(
"selectCfmWtPSEStart",
"selectCfmWtPSEEnd",
"timeCfmWtPSE",
"cfmWtPSE",
);
break;
case "cfmWtPSEEnd":
handleDoubleSelection(
"selectCfmWtPSEEnd",
"selectCfmWtPSEStart",
"timeCfmWtPSE",
"cfmWtPSE",
);
break;
// Cycle time
case "cfmCtEteStart":
handleSingleSelection(
"cfmCtEteStart",
"timeCfmCtEteStart",
"cfmCtEteStart",
);
break;
case "cfmCtEteEnd":
handleSingleSelection("cfmCtEteEnd", "timeCfmCtEteEnd", "cfmCtEteEnd");
break;
case "cfmCtEteSEStart":
handleDoubleSelection(
"selectCfmCtEteSEStart",
"selectCfmCtEteSEEnd",
"timeCfmCtEteSE",
"cfmCtEteSE",
);
break;
case "cfmCtEteSEEnd":
handleDoubleSelection(
"selectCfmCtEteSEEnd",
"selectCfmCtEteSEStart",
"timeCfmCtEteSE",
"cfmCtEteSE",
);
break;
default:
break;
}
});
emitter.on("reset", (data) => {
reset();
});
emitter.on("isRadioChange", (data) => {
if (data) {
reset();
switch (selectedRuleType.value) {
case "Processing time":
state.timeCfmPtEteAll = getDurationTime(cfmPtEteWhole.value, "all");
state.timeCfmPtEteAllDefault = cloneDeep(state.timeCfmPtEteAll);
break;
case "Waiting time":
state.timeCfmWtEteAll = getDurationTime(cfmWtEteWhole.value, "all");
state.timeCfmWtEteAllDefault = cloneDeep(state.timeCfmWtEteAll);
break;
case "Cycle time":
state.timeCfmCtEteAll = getDurationTime(cfmCtEteWhole.value, "all");
state.timeCfmCtEteAllDefault = cloneDeep(state.timeCfmCtEteAll);
break;
default:
break;
}
}
});
emitter.on("isRadioProcessScopeChange", (data) => {
if (data) {
reset();
}
});
emitter.on("isRadioActSeqMoreChange", (data) => {
if (data) {
if (selectedActSeqMore.value === "All") {
switch (selectedRuleType.value) {
case "Processing time":
state.timeCfmPtEteAll = getDurationTime(cfmPtEteWhole.value, "all");
state.timeCfmPtEteAllDefault = cloneDeep(state.timeCfmPtEteAll);
break;
case "Waiting time":
state.timeCfmWtEteAll = getDurationTime(cfmWtEteWhole.value, "all");
state.timeCfmWtEteAllDefault = cloneDeep(state.timeCfmWtEteAll);
break;
case "Cycle time":
state.timeCfmCtEteAll = getDurationTime(cfmCtEteWhole.value, "all");
state.timeCfmCtEteAllDefault = cloneDeep(state.timeCfmCtEteAll);
break;
default:
break;
}
} else reset();
}
});
emitter.on("isRadioActSeqFromToChange", (data) => {
if (data) {
reset();
}
});
onBeforeUnmount(() => {
emitter.off("actRadioData");
emitter.off("reset");
emitter.off("isRadioChange");
emitter.off("isRadioProcessScopeChange");
emitter.off("isRadioActSeqMoreChange");
emitter.off("isRadioActSeqFromToChange");
});
</script>

View File

@@ -1,33 +0,0 @@
<template>
<ul class="space-y-2" id="cyp-conformance-result-arrow">
<li
class="flex justify-start items-center pr-4"
v-for="(act, index) in data"
:key="index"
:title="act"
>
<span class="material-symbols-outlined text-primary mr-2">
arrow_circle_down
</span>
<p
class="px-2 py-1 border border-neutral-500 w-full whitespace-nowrap break-keep text-ellipsis overflow-hidden"
>
{{ act }}
</p>
</li>
</ul>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ResultArrow
* Conformance result display with arrow icons showing activity
* sequences.
*/
defineProps(["data", "select"]);
</script>

View File

@@ -1,58 +0,0 @@
<template>
<ul class="space-y-2" id="cyp-conformance-result-check">
<li
class="flex justify-start items-center pr-4"
v-for="(act, index) in displayData"
:key="index"
:title="act"
>
<span class="material-symbols-outlined text-primary mr-2">
check_circle
</span>
<p
class="px-2 py-1 border border-neutral-500 w-full whitespace-nowrap break-keep text-ellipsis overflow-hidden"
>
{{ act }}
</p>
</li>
</ul>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ResultCheck
* Conformance result display with check-circle icons showing
* matched activities.
*/
import { ref, watch, onBeforeUnmount } from "vue";
import emitter from "@/utils/emitter";
const props = defineProps(["data", "select"]);
const displayData = ref(props.select);
watch(
() => props.data,
(newValue) => {
displayData.value = newValue;
},
);
watch(
() => props.select,
(newValue) => {
displayData.value = newValue;
},
);
emitter.on("reset", (val) => (displayData.value = val));
onBeforeUnmount(() => {
emitter.off("reset");
});
</script>

View File

@@ -1,52 +0,0 @@
<template>
<ul id="cyp-conformance-result-dot">
<li
class="flex justify-start items-center py-1 pr-4"
v-for="(act, index) in data"
:key="index + act"
:title="act"
>
<span class="material-symbols-outlined disc !text-sm align-middle mr-1"
>fiber_manual_record</span
>
<span class="mr-2 block w-12">{{ act.category }}</span>
<span
class="px-2 py-1 border border-neutral-500 w-full whitespace-nowrap break-keep text-ellipsis overflow-hidden block"
>{{ act.task }}</span
>
</li>
</ul>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/ResultDot
* Conformance result display with dot icons showing category
* and task pairs.
*/
import { ref, watch, onBeforeUnmount } from "vue";
import emitter from "@/utils/emitter";
const props = defineProps(["timeResultData", "select"]);
const data = ref(props.select);
watch(
() => props.timeResultData,
(newValue) => {
data.value = newValue;
},
{ deep: true },
);
emitter.on("reset", (val) => (data.value = val));
onBeforeUnmount(() => {
emitter.off("reset");
});
</script>

View File

@@ -1,113 +0,0 @@
<template>
<div id="timeranges_s_e_container" class="flex justify-between items-center">
<Durationjs
:max="minVuemax"
:min="minVuemin"
:size="'min'"
:updateMax="updateMax"
@total-seconds="minTotalSeconds"
:value="durationMin"
>
</Durationjs>
<span>~</span>
<Durationjs
:max="maxVuemax"
:min="maxVuemin"
:size="'max'"
:updateMin="updateMin"
@total-seconds="maxTotalSeconds"
:value="durationMax"
>
</Durationjs>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/ConformanceSidebar/TimeRangeDuration
* Time range duration picker with min/max duration inputs
* for conformance time-based rules.
*/
import { ref, watch } from "vue";
import Durationjs from "@/components/DurationInput.vue";
import { cloneDeep } from "lodash-es";
const props = defineProps(["time", "select"]);
const emit = defineEmits(["min-total-seconds", "max-total-seconds"]);
const timeData = ref({ min: 0, max: 0 });
const timeRangeMin = ref(0);
const timeRangeMax = ref(0);
const minVuemin = ref(0);
const minVuemax = ref(0);
const maxVuemin = ref(0);
const maxVuemax = ref(0);
const updateMax = ref(null);
const updateMin = ref(null);
const durationMin = ref(null);
const durationMax = ref(null);
/** Deep-copies timeData min/max values to the Vue component boundaries. */
function setTimeValue() {
// Deep copy the original timeData values
minVuemin.value = cloneDeep(timeData.value.min);
minVuemax.value = cloneDeep(timeData.value.max);
maxVuemin.value = cloneDeep(timeData.value.min);
maxVuemax.value = cloneDeep(timeData.value.max);
}
/**
* Handles the minimum duration total seconds update.
* @param {number} e - The total seconds from the min duration component.
*/
function minTotalSeconds(e) {
timeRangeMin.value = e;
updateMin.value = e;
emit("min-total-seconds", e);
}
/**
* Handles the maximum duration total seconds update.
* @param {number} e - The total seconds from the max duration component.
*/
function maxTotalSeconds(e) {
timeRangeMax.value = e;
updateMax.value = e;
emit("max-total-seconds", e);
}
watch(
() => props.time,
(newValue, oldValue) => {
durationMax.value = null;
durationMin.value = null;
if (newValue === null) {
timeData.value = { min: 0, max: 0 };
} else if (newValue !== null) {
timeData.value = { min: newValue.min, max: newValue.max };
emit("min-total-seconds", newValue.min);
emit("max-total-seconds", newValue.max);
}
setTimeValue();
},
{ deep: true, immediate: true },
);
// created
if (props.select) {
if (Object.keys(props.select.base).length !== 0) {
timeData.value = props.select.base;
setTimeValue();
}
if (Object.keys(props.select.rule).length !== 0) {
durationMin.value = props.select.rule.min;
durationMax.value = props.select.rule.max;
}
}
</script>

View File

@@ -1,447 +0,0 @@
<template>
<Dialog
:visible="listModal"
@update:visible="emit('closeModal', $event)"
modal
:style="{ width: '90vw', height: '90vh' }"
:contentClass="contentClass"
>
<template #header>
<div class="py-5">
<p class="text-base font-bold">Non-conformance Issue</p>
</div>
</template>
<div class="h-full flex items-start justify-start p-4">
<!-- Trace List -->
<section class="w-80 h-full pr-4">
<p class="h2 px-2 mb-2">Trace List ({{ traceTotal }})</p>
<p class="text-primary h2 px-2 mb-2">
<span class="material-symbols-outlined !text-sm align-[-10%] mr-2"
>info</span
>Click trace number to see more.
</p>
<div
class="overflow-y-scroll overflow-x-hidden scrollbar mx-[-8px] max-h-[calc(100%_-_96px)]"
>
<table class="border-separate border-spacing-x-2 text-sm">
<caption class="hidden">
Trace List
</caption>
<thead class="sticky top-0 z-10 bg-neutral-100">
<tr>
<th class="h2 px-2 border-b border-neutral-500">Trace</th>
<th
class="h2 px-2 border-b border-neutral-500 text-start"
colspan="3"
>
Occurrences
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(trace, key) in traceList"
:key="key"
class="cursor-pointer hover:text-primary"
@click="switchCaseData(trace.id)"
>
<td class="p-2">#{{ trace.id }}</td>
<td class="p-2 w-24">
<div
class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden"
>
<div
class="h-full bg-primary"
:style="progressWidth(trace.value)"
></div>
</div>
</td>
<td class="py-2 text-right">{{ trace.count }}</td>
<td class="p-2 text-right">{{ trace.ratio }}%</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Trace item Table -->
<section
class="px-4 py-2 h-full w-[calc(100%_-_320px)] bg-neutral-10 rounded-xl"
>
<p class="h2 mb-2 px-4">Trace #{{ showTraceId }}</p>
<div class="h-36 w-full px-2 mb-2 border border-neutral-300 rounded">
<div class="h-full w-full">
<div
id="cfmTrace"
ref="cfmTrace"
class="h-full min-w-full relative"
></div>
</div>
</div>
<div
class="overflow-y-auto overflow-x-auto scrollbar h-[calc(100%_-_200px)] infiniteTable"
@scroll="handleScroll"
>
<DataTable
:value="caseData"
showGridlines
tableClass="text-sm"
breakpoint="0"
>
<div v-for="col in columnData" :key="col.field">
<Column :field="col.field" :header="col.header">
<template #body="{ data }">
<div
:class="
data[col.field]?.length > 18
? 'whitespace-normal'
: 'whitespace-nowrap'
"
>
{{ data[col.field] }}
</div>
</template>
</Column>
</div>
</DataTable>
</div>
</section>
</div>
</Dialog>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Conformance/MoreModal
* Modal dialog showing detailed conformance check
* results with expandable activity sequences.
*/
import { ref, computed, watch, nextTick, useTemplateRef, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useConformanceStore } from "@/stores/conformance";
import cytoscapeMapTrace from "@/module/cytoscapeMapTrace.js";
import { cloneDeep } from "lodash-es";
const props = defineProps([
"listModal",
"listNo",
"traceId",
"firstCases",
"listTraces",
"taskSeq",
"cases",
"category",
]);
const emit = defineEmits(["closeModal"]);
const conformanceStore = useConformanceStore();
const { infinite404 } = storeToRefs(conformanceStore);
// template ref
const cfmTrace = useTemplateRef("cfmTrace");
// data
const contentClass = ref("!bg-neutral-100 border-t border-neutral-300 h-full");
const showTraceId = ref(null);
const infiniteData = ref(null);
const maxItems = ref(false);
const infiniteFinish = ref(true); // Whether infinite scroll loading is complete
const startNum = ref(0);
const cyTraceInstance = ref(null);
const processMap = ref({
nodes: [],
edges: [],
});
// computed
const traceTotal = computed(() => {
return traceList.value.length;
});
const traceList = computed(() => {
const sum = props.listTraces
.map((trace) => trace.count)
.reduce((acc, cur) => acc + cur, 0);
if (sum === 0) return [];
return props.listTraces
.map((trace) => {
return {
id: trace.id,
value: Number(getPercentLabel(trace.count / sum)),
count: trace.count.toLocaleString("en-US"),
count_base: trace.count,
ratio: getPercentLabel(trace.count / sum),
};
})
.sort((x, y) => x.id - y.id);
});
const caseData = computed(() => {
if (infiniteData.value !== null) {
const data = cloneDeep(infiniteData.value);
data.forEach((item) => {
item.facets.forEach((facet, index) => {
item[`fac_${index}`] = facet.value; // Create a new key-value pair
});
delete item.facets; // Remove the original facets property
item.attributes.forEach((attribute, index) => {
item[`att_${index}`] = attribute.value; // Create a new key-value pair
});
delete item.attributes; // Remove the original attributes property
});
return data;
}
return [];
});
const columnData = computed(() => {
const data = cloneDeep(props.cases);
if (!data || data.length === 0) return [];
const facetName = (facName) =>
facName
.trim()
.replace(
/^(.)(.*)$/,
(match, firstChar, restOfString) =>
firstChar.toUpperCase() + restOfString.toLowerCase(),
);
const result = [
{ field: "id", header: "Case Id" },
{ field: "started_at", header: "Start time" },
{ field: "completed_at", header: "End time" },
...(data[0].facets ?? []).map((fac, index) => ({
field: `fac_${index}`,
header: facetName(fac.name),
})),
...(data[0].attributes ?? []).map((att, index) => ({
field: `att_${index}`,
header: att.key,
})),
];
return result;
});
// watch
watch(
() => props.listModal,
(newValue) => {
// Draw the chart when the modal is opened for the first time
if (newValue) createCy();
},
);
watch(
() => props.taskSeq,
(newValue) => {
if (newValue !== null) createCy();
},
);
watch(
() => props.traceId,
(newValue) => {
// Update showTraceId when the traceId prop changes
showTraceId.value = newValue;
},
);
watch(showTraceId, (newValue, oldValue) => {
const isScrollTop = document.querySelector(".infiniteTable");
if (isScrollTop && isScrollTop.scrollTop !== undefined)
if (newValue !== oldValue) isScrollTop.scrollTop = 0;
});
watch(
() => props.firstCases,
(newValue) => {
infiniteData.value = newValue;
},
);
watch(infinite404, (newValue) => {
if (newValue === 404) maxItems.value = true;
});
// methods
/**
* Number to percentage
* @param {number} val - The raw ratio value.
* @returns {string} The formatted percentage string.
*/
function getPercentLabel(val) {
if (Number((val * 100).toFixed(1)) >= 100) return 100;
else return Number.parseFloat((val * 100).toFixed(1));
}
/**
* set progress bar width
* @param {number} value - The percentage value.
* @returns {string} The CSS width style string.
*/
function progressWidth(value) {
return `width:${value}%;`;
}
/**
* switch case data
* @param {number} id case id
*/
async function switchCaseData(id) {
if (id === showTraceId.value) return;
infinite404.value = null;
maxItems.value = false;
startNum.value = 0;
let result = null;
if (props.category === "issue")
result = await conformanceStore.getConformanceTraceDetail(
props.listNo,
id,
0,
);
else if (props.category === "loop")
result = await conformanceStore.getConformanceLoopsTraceDetail(
props.listNo,
id,
0,
);
infiniteData.value = result;
showTraceId.value = id; // Set after getDetail so the case table finishes loading before switching showTraceId
}
/**
* Assembles the trace element nodes data for Cytoscape rendering.
*/
function setNodesData() {
// Clear nodes to prevent accumulation on each render
processMap.value.nodes = [];
// Populate nodes with data returned from the API call
if (props.taskSeq !== null) {
props.taskSeq.forEach((node, index) => {
processMap.value.nodes.push({
data: {
id: index,
label: node,
backgroundColor: "#CCE5FF",
bordercolor: "#003366",
shape: "round-rectangle",
height: 80,
width: 100,
},
});
});
}
}
/**
* Assembles the trace edge line data for Cytoscape rendering.
*/
function setEdgesData() {
processMap.value.edges = [];
if (props.taskSeq !== null) {
props.taskSeq.forEach((edge, index) => {
processMap.value.edges.push({
data: {
source: `${index}`,
target: `${index + 1}`,
lineWidth: 1,
style: "solid",
},
});
});
}
// The number of edges is one less than the number of nodes
processMap.value.edges.pop();
}
/**
* create trace cytoscape's map
*/
function createCy() {
nextTick(() => {
const graphId = cfmTrace.value;
setNodesData();
setEdgesData();
cyTraceInstance.value?.destroy();
if (graphId !== null)
cyTraceInstance.value = cytoscapeMapTrace(
processMap.value.nodes,
processMap.value.edges,
graphId,
);
});
}
/**
* Infinite scroll: loads more data.
*/
async function fetchData() {
try {
infiniteFinish.value = false;
startNum.value += 20;
const result = await conformanceStore.getConformanceTraceDetail(
props.listNo,
showTraceId.value,
startNum.value,
);
if (result) infiniteData.value = [...infiniteData.value, ...result];
infiniteFinish.value = true;
} catch (error) {
console.error("Failed to load data:", error);
infiniteFinish.value = true;
}
}
/**
* Infinite scroll: listens for scroll reaching the bottom.
* @param {Event} event - The scroll event.
*/
function handleScroll(event) {
if (
maxItems.value ||
(infiniteData.value?.length ?? 0) < 20 ||
infiniteFinish.value === false
)
return;
const container = event.target;
const overScrollHeight =
container.scrollTop + container.clientHeight + 20 >= container.scrollHeight;
if (overScrollHeight) fetchData();
}
onBeforeUnmount(() => {
cyTraceInstance.value?.destroy();
});
</script>
<style scoped>
@reference "../../../assets/tailwind.css";
/* Progress bar color */
:deep(.p-progressbar .p-progressbar-value) {
@apply bg-primary;
}
/* Table set */
:deep(.p-datatable-thead) {
@apply sticky top-0 left-0 z-10 bg-neutral-10;
}
:deep(.p-datatable .p-datatable-thead > tr > th) {
@apply !border-y-0 border-neutral-500 bg-neutral-100 after:absolute after:left-0 after:w-full after:h-full after:block after:top-0 after:border-b after:border-t after:border-neutral-500;
white-space: nowrap;
}
:deep(.p-datatable .p-datatable-tbody > tr > td) {
@apply border-neutral-500 !border-t-0 text-center;
}
/* Center header title */
:deep(.p-column-header-content) {
@apply justify-center;
}
:deep(.p-datatable.p-datatable-gridlines .p-datatable-tbody > tr > td) {
min-width: 72px;
max-width: 184px;
overflow-wrap: break-word;
word-wrap: break-word;
}
</style>

View File

@@ -1,258 +0,0 @@
<template>
<!-- Activity List -->
<div
class="bg-neutral-10 border border-neutral-300 rounded-xl px-4 w-full h-full"
>
<div class="flex justify-between items-center my-2 flex-wrap">
<p class="h2">Activity List&nbsp;({{ data.length }})</p>
</div>
<!-- Table -->
<div
class="overflow-y-auto overflow-x-auto scrollbar -mx-2 h-[calc(100%_-_64px)]"
>
<table
class="border-separate border-spacing-x-2 table-auto min-w-full text-sm"
:class="data.length === 0 ? 'h-full' : null"
>
<caption class="hidden">
Activity List
</caption>
<thead class="sticky top-0 left-0 z-10 bg-neutral-10">
<tr>
<th
class="text-start font-semibold leading-10 px-2 border-b border-neutral-500"
>
Activity
</th>
<th
class="font-semibold leading-10 px-2 border-b border-neutral-500 text-start"
colspan="3"
>
Occurrences
</th>
</tr>
</thead>
<Draggable
:list="data"
:group="{ name: 'activity', pull: 'clone', put: false }"
itemKey="name"
tag="tbody"
animation="300"
@end="onEnd"
:fallbackTolerance="5"
:forceFallback="true"
:ghostClass="'ghostSelected'"
:dragClass="'dragSelected'"
:sort="false"
>
<template #item="{ element, index }">
<tr
@dblclick="moveActItem(index, element)"
:class="listSequence.includes(element) ? 'text-primary' : ''"
>
<td class="px-4 py-2" :id="element.label">{{ element.label }}</td>
<td class="px-4 py-2 w-24">
<div
class="h-4 min-w-[96px] bg-neutral-300 rounded-sm overflow-hidden"
>
<div
class="h-full bg-primary"
:style="progressWidth(element.occ_value)"
></div>
</div>
</td>
<td class="px-4 py-2 text-right">{{ element.occurrences }}</td>
<td class="px-4 py-2 text-right">
{{ element.occurrence_ratio }}
</td>
</tr>
</template>
</Draggable>
</table>
</div>
</div>
<!-- Sequence -->
<div
class="bg-neutral-10 border border-neutral-300 rounded-xl px-4 pb-4 w-full h-full relative text-sm"
>
<p class="h2 border-b border-500 my-2">
Sequence&nbsp;({{ listSeq.length }})
</p>
<!-- No Data -->
<div
v-if="listSequence.length === 0"
class="p-4 w-[calc(100%_-_32px)] h-5/6 flex justify-center items-center absolute"
>
<p class="text-neutral-500">
Please drag and drop at least two activities here and sort.
</p>
</div>
<!-- Have Data -->
<div class="py-4 m-auto w-full h-[calc(100%_-_56px)]">
<div
class="w-full h-full overflow-y-auto overflow-x-auto scrollbar px-4 text-center listSequence"
>
<draggable
class="h-full"
:list="listSequence"
:group="{ name: 'activity' }"
itemKey="name"
animation="300"
:forceFallback="true"
:fallbackTolerance="5"
:dragClass="'!opacity-100'"
@start="onStart"
@end="onEnd"
:component-data="getComponentData()"
:ghostClass="'!opacity-0'"
>
<template #item="{ element, index }">
<div>
<div class="flex justify-center items-center">
<div
class="w-full p-2 border border-primary rounded text-primary bg-neutral-10"
@dblclick="moveSeqItem(index, element)"
>
<span>{{ element.label }}</span>
</div>
<span
class="material-symbols-outlined pl-1 cursor-pointer duration-300 hover:text-danger"
@click.stop="moveSeqItem(index, element)"
>close</span
>
</div>
<span
v-show="
index !== listSeq.length - 1 && index !== lastItemIndex - 1
"
class="pi pi-chevron-down !text-lg inline-block py-2 pr-7"
></span>
</div>
</template>
</draggable>
</div>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/Map/Filter/ActAndSeq Activity list
* with drag-and-drop sequence builder for creating filter
* rules.
*/
import { ref, computed, watch } from "vue";
import { sortNumEngZhtwForFilter } from "@/module/sortNumEngZhtw.js";
const props = defineProps({
filterTaskData: {
type: Array,
required: true,
},
progressWidth: {
type: Function,
required: false,
},
listSeq: {
type: Array,
required: true,
},
});
const emit = defineEmits(["update:listSeq"]);
const listSequence = ref([]);
const filteredData = ref(props.filterTaskData);
const lastItemIndex = ref(null);
const data = computed(() => {
// Sort the Activity List
return [...filteredData.value].sort((x, y) => {
const diff = y.occurrences - x.occurrences;
return diff === 0 ? sortNumEngZhtwForFilter(x.label, y.label) : diff;
});
});
watch(
() => props.listSeq,
(newval) => {
listSequence.value = newval;
},
);
watch(
() => props.filterTaskData,
(newval) => {
filteredData.value = newval;
},
);
/**
* Moves an activity from the list to the sequence on double-click.
* @param {number} index - The item index in the activity list.
* @param {Object} element - The activity data object.
*/
function moveActItem(index, element) {
listSequence.value.push(element);
}
/**
* Removes an activity from the sequence on double-click.
* @param {number} index - The item index in the sequence.
* @param {Object} element - The activity data object.
*/
function moveSeqItem(index, element) {
listSequence.value.splice(index, 1);
}
/** Emits the current sequence list to the parent component. */
function getComponentData() {
emit("update:listSeq", listSequence.value);
}
/**
* Handles drag start: hides original element and last arrow.
* @param {Event} evt - The drag start event.
*/
function onStart(evt) {
const lastChild = evt.to.lastChild.lastChild;
lastChild.style.display = "none";
// Hide the dragged element at its original position
const originalElement = evt.item;
originalElement.style.display = "none";
// When dragging the last element, hide the arrow of the second-to-last element
const listIndex = listSequence.value.length - 1;
if (evt.oldIndex === listIndex) lastItemIndex.value = listIndex;
}
/**
* Handles drag end: restores element visibility.
* @param {Event} evt - The drag end event.
*/
function onEnd(evt) {
// Show the dragged element
const originalElement = evt.item;
originalElement.style.display = "";
// Show the arrow after drag ends, except for the last element
const lastChild = evt.item.lastChild;
const listIndex = listSequence.value.length - 1;
if (evt.oldIndex !== listIndex) {
lastChild.style.display = "";
}
// Reset: hide the second-to-last element's arrow when dragging the last element
lastItemIndex.value = null;
}
</script>
<style scoped>
@reference "../../../../assets/tailwind.css";
.ghostSelected {
@apply shadow-[0px_0px_100px_-10px_inset] shadow-neutral-200;
}
.dragSelected {
@apply shadow-[0px_0px_4px_2px] bg-neutral-10 shadow-neutral-300 !opacity-100;
}
</style>

View File

@@ -1,121 +0,0 @@
<template>
<div
class="bg-neutral-10 border border-neutral-300 rounded-xl px-4 w-full h-full"
>
<div class="flex justify-between items-center my-2 flex-wrap">
<p class="h2">{{ tableTitle }}&nbsp;({{ tableData.length }})</p>
</div>
<!-- Table -->
<div
class="overflow-y-auto overflow-x-auto scrollbar -mx-2 h-[calc(100%_-_64px)]"
>
<DataTable
v-model:selection="select"
:value="tableData"
dataKey="label"
breakpoint="0"
tableClass="w-full !border-separate !border-spacing-x-2 !table-auto text-sm"
@row-select="onRowSelect"
>
<ColumnGroup type="header">
<Row>
<Column
selectionMode="single"
headerClass="w-8 !p-2 !bg-neutral-10 !border-neutral-500 sticky top-0 left-0 z-10 bg-neutral-10"
></Column>
<Column
field="label"
header="Activity"
headerClass="!bg-neutral-10 !border-neutral-500 !py-2 sticky top-0 left-0 z-10 bg-neutral-10"
sortable
/>
<Column
field="occurrences_base"
header="Occurrences"
headerClass="!bg-neutral-10 !border-neutral-500 !py-2 sticky top-0 left-0 z-10 bg-neutral-10"
sortable
:colspan="3"
/>
</Row>
</ColumnGroup>
<Column selectionMode="single" bodyClass="!p-2 !border-0"></Column>
<Column
field="label"
header="Activity"
bodyClass="break-words !py-2 !border-0"
></Column>
<Column header="Progress" bodyClass="!py-2 !border-0 min-w-[96px]">
<template #body="slotProps">
<div class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden">
<div
class="h-full bg-primary"
:style="progressWidth(slotProps.data.occ_value)"
></div>
</div>
</template>
</Column>
<Column
field="occurrences"
header="Occurrences"
bodyClass="!text-right !py-2 !border-0"
></Column>
<Column
field="occurrence_ratio"
header="Occurrence Ratio"
bodyClass="!text-right !py-2 !border-0"
></Column>
</DataTable>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/Map/Filter/ActOcc Activity
* occurrences filter table with single-row radio selection.
*/
import { ref, watch } from "vue";
const props = defineProps({
tableTitle: {
type: String,
required: true,
},
tableData: {
type: Array,
required: true,
},
tableSelect: {
type: [Object, Array],
default: null,
},
progressWidth: {
type: Function,
required: false,
},
});
const emit = defineEmits(["on-row-select"]);
const select = ref(null);
const metaKey = ref(true);
watch(
() => props.tableSelect,
(newval) => {
select.value = newval;
},
);
/**
* Emits the selected row to the parent component.
* @param {Event} e - The row selection event.
*/
function onRowSelect(e) {
emit("on-row-select", e);
}
</script>

View File

@@ -1,158 +0,0 @@
<template>
<div class="bg-neutral-10 border border-neutral-300 rounded-xl px-4 h-full">
<div class="flex justify-between items-center my-2">
<p class="h2">{{ tableTitle }}&nbsp;({{ data.length }})</p>
</div>
<!-- Table -->
<div
class="overflow-y-auto overflow-x-auto scrollbar -mx-2 h-[calc(100%_-_64px)]"
>
<DataTable
v-model:selection="select"
:value="data"
breakpoint="0"
tableClass="w-full !border-separate !border-spacing-x-2 !table-auto text-sm"
@row-select="onRowSelect"
@row-unselect="onRowUnselect"
@row-select-all="onRowSelectAll"
@row-unselect-all="onRowUnelectAll"
>
<ColumnGroup type="header">
<Row>
<Column
selectionMode="multiple"
headerClass="w-8 !p-2 !bg-neutral-10 !border-neutral-500 sticky top-0 left-0 z-10 bg-neutral-10 allCheckboxAct"
></Column>
<Column
field="label"
header="Activity"
headerClass="!bg-neutral-10 !border-neutral-500 !py-2 sticky top-0 left-0 z-10 bg-neutral-10"
sortable
/>
<Column
field="occurrences_base"
header="Occurrences"
headerClass="!bg-neutral-10 !border-neutral-500 !py-2 sticky top-0 left-0 z-10 bg-neutral-10"
sortable
:colspan="3"
/>
<Column
field="cases_base"
headerClass="!bg-neutral-10 !border-neutral-500 !py-2 sticky top-0 left-0 z-10 bg-neutral-10"
header="Cases with Activity"
sortable
:colspan="3"
/>
</Row>
</ColumnGroup>
<Column selectionMode="multiple" bodyClass="!p-2 !border-0"></Column>
<Column
field="label"
header="Activity"
bodyClass="break-words !py-2 !border-0"
></Column>
<Column header="Progress" bodyClass="!py-2 !border-0 min-w-[96px]">
<template #body="slotProps">
<div class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden">
<div
class="h-full bg-primary"
:style="progressWidth(slotProps.data.occ_value)"
></div>
</div>
</template>
</Column>
<Column
field="occurrences"
header="Occurrences"
bodyClass="!text-right !py-2 !border-0"
></Column>
<Column
field="occurrence_ratio"
header="O2"
bodyClass="!text-right !py-2 !border-0"
></Column>
<Column header="Progress" bodyClass="!py-2 !border-0 min-w-[96px]">
<template #body="slotProps">
<div class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden">
<div
class="h-full bg-primary"
:style="progressWidth(slotProps.data.case_value)"
></div>
</div>
</template>
</Column>
<Column
field="cases"
header="Cases with Activity"
bodyClass="!text-right !py-2 !border-0"
></Column>
<Column
field="case_ratio"
header="C2"
bodyClass="!text-right !py-2 !border-0"
></Column>
</DataTable>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/Map/Filter/ActOccCase Activity
* occurrences and cases filter table with multi-row checkbox
* selection.
*/
import { ref, watch } from "vue";
const props = defineProps([
"tableTitle",
"tableData",
"tableSelect",
"progressWidth",
]);
const emit = defineEmits(["on-row-select"]);
const select = ref(null);
const data = ref(props.tableData);
watch(
() => props.tableSelect,
(newval) => {
select.value = newval;
},
);
/** Emits the current selection when a row is selected. */
function onRowSelect() {
emit("on-row-select", select.value);
}
/** Emits the current selection when a row is unselected. */
function onRowUnselect() {
emit("on-row-select", select.value);
}
/**
* Handles select-all rows action.
* @param {Event} e - The select-all event with data property.
*/
function onRowSelectAll(e) {
select.value = e.data;
emit("on-row-select", select.value);
}
/**
* Handles unselect-all rows action.
* @param {Event} e - The unselect-all event.
*/
function onRowUnelectAll(e) {
select.value = null;
emit("on-row-select", select.value);
}
</script>

View File

@@ -1,964 +0,0 @@
<template>
<section class="w-full h-full">
<p class="h2 ml-1 mb-2">Activity Select</p>
<div
class="flex flex-row justify-between items-start gap-4 w-full h-[calc(100%_-_48px)]"
>
<!-- Attribute Name -->
<div
class="basis-1/3 bg-neutral-10 border border-neutral-300 rounded-xl px-4 pb-4 w-full h-full relative text-sm"
>
<p class="h2 my-2">
Attribute Name ({{ attTotal }})<span
class="material-symbols-outlined !text-sm align-middle ml-2 cursor-pointer"
v-tooltip.bottom="tooltip.attributeName"
>info</span
>
</p>
<div
class="overflow-y-auto overflow-x-auto scrollbar -mx-2 h-[calc(100%_-_56px)]"
>
<DataTable
v-model:selection="selectedAttName"
:value="filterAttrs"
dataKey="key"
breakpoint="0"
:tableClass="tableClass"
@row-select="switchAttNameRadio"
>
<Column
selectionMode="single"
:headerClass="headerModeClass"
:bodyClass="bodyModeClass"
></Column>
<Column
field="key"
header="Attribute"
:headerClass="headerClass"
:bodyClass="bodyClass"
sortable
>
<template #body="slotProps">
<div
:title="slotProps.data.key"
class="whitespace-nowrap break-keep overflow-hidden text-ellipsis w-full"
>
{{ slotProps.data.key }}
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Range Selection -->
<div
class="basis-2/3 bg-neutral-10 border border-neutral-300 rounded-xl px-4 w-full h-full"
>
<div class="flex justify-between items-center my-2 flex-wrap">
<p class="h2">Range Selection {{ attRangeTotal }}</p>
</div>
<div
class="overflow-y-auto overflow-x-auto scrollbar -mx-2 w-full h-[calc(100%_-_70px)]"
>
<!-- type: boolean -->
<div v-if="selectedAttName.type === 'boolean'" class="w-full">
<DataTable
v-model:selection="selectedAttRange"
:value="attRangeData"
dataKey="id"
breakpoint="0"
:tableClass="tableClass"
@row-select="onRowSelectionChange"
>
<ColumnGroup type="header">
<Row>
<Column
selectionMode="single"
:headerClass="headerModeClass"
></Column>
<Column
field="value"
header="Value"
:headerClass="headerClass"
sortable
/>
<Column
field="freq"
header="Occurrences"
:headerClass="headerClass"
sortable
:colspan="3"
/>
</Row>
</ColumnGroup>
<Column
selectionMode="single"
:bodyClass="bodyModeClass"
></Column>
<Column field="label" header="Activity" :bodyClass="bodyClass">
<template #body="slotProps">
<div
:title="slotProps.data.label"
class="whitespace-nowrap break-keep overflow-hidden text-ellipsis w-full"
>
{{ slotProps.data.label }}
</div>
</template>
</Column>
<Column
header="Progress"
bodyClass="!py-2 !border-0 min-w-[96px]"
>
<template #body="slotProps">
<div
class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden"
>
<div
class="h-full bg-primary"
:style="progressWidth(slotProps.data.occ_progress_bar)"
></div>
</div>
</template>
</Column>
<Column
field="occ_value"
header="Occurrences"
bodyClass="!text-right !py-2 !border-0"
></Column>
<Column
field="occ_ratio"
header="Occurrence Ratio"
bodyClass="!text-right !py-2 !border-0"
></Column>
</DataTable>
</div>
<!-- type: string -->
<div v-else-if="selectedAttName.type === 'string'" class="w-full">
<DataTable
v-model:selection="selectedAttRange"
:value="attRangeData"
dataKey="id"
breakpoint="0"
tableClass="w-full !border-separate !border-spacing-x-2 !table-auto text-sm"
@row-select="onRowSelectionChange"
@row-unselect="onRowSelectionChange"
@row-select-all="onRowSelectAll($event)"
@row-unselect-all="onRowUnelectAll"
>
<ColumnGroup type="header">
<Row>
<Column
selectionMode="multiple"
:headerClass="headerModeClass"
></Column>
<Column
field="value"
header="Value"
:headerClass="headerClass"
sortable
/>
<Column
field="freq"
header="Occurrences"
:headerClass="headerClass"
sortable
:colspan="3"
/>
</Row>
</ColumnGroup>
<Column
selectionMode="multiple"
:bodyClass="bodyModeClass"
></Column>
<Column field="value" header="Activity" :bodyClass="bodyClass">
<template #body="slotProps">
<div
:title="slotProps.data.value"
class="whitespace-nowrap break-keep overflow-hidden text-ellipsis w-full"
>
{{ slotProps.data.value }}
</div>
</template>
</Column>
<Column
header="Progress"
bodyClass="!py-2 !border-0 min-w-[96px]"
>
<template #body="slotProps">
<div
class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden"
>
<div
class="h-full bg-primary"
:style="progressWidth(slotProps.data.occ_progress_bar)"
></div>
</div>
</template>
</Column>
<Column
field="occ_value"
header="Occurrences"
bodyClass="!text-right !py-2 !border-0"
></Column>
<Column
field="occ_ratio"
header="Occurrence Ratio"
bodyClass="!text-right !py-2 !border-0"
></Column>
</DataTable>
</div>
<!-- type: value -->
<div
v-else-if="valueTypes.has(selectedAttName.type)"
class="space-y-2 text-sm w-full"
>
<!-- Chart.js -->
<div class="h-3/5 relative">
<Chart
type="line"
:data="chartData"
:options="chartOptions"
class="h-30rem"
id="chartCanvasId"
/>
<div id="chart-mask-left" class="absolute bg-neutral-10/50"></div>
<div
id="chart-mask-right"
class="absolute bg-neutral-10/50"
></div>
</div>
<!-- Slider -->
<div class="px-2 py-3">
<Slider
v-model="selectArea"
:step="1"
:min="0"
:max="selectRange"
range
class="mx-2"
@change="changeSelectArea($event)"
/>
</div>
<!-- Calendar / InputNumber group -->
<div>
<div
v-if="selectedAttName.type === 'date'"
class="flex justify-center items-center space-x-2 w-full"
>
<div>
<span class="block mb-2">Start time</span>
<DatePicker
v-model="startTime"
dateFormat="yy/mm/dd"
:panelProps="panelProps"
:minDate="startMinDate"
:maxDate="startMaxDate"
showTime
showIcon
hourFormat="24"
@date-select="sliderValueRange($event, 'start')"
id="startCalendar"
/>
</div>
<span class="block mt-4">~</span>
<div>
<span class="block mb-2">End time</span>
<DatePicker
v-model="endTime"
dateFormat="yy/mm/dd"
:panelProps="panelProps"
:minDate="endMinDate"
:maxDate="endMaxDate"
showTime
showIcon
hourFormat="24"
@date-select="sliderValueRange($event, 'end')"
id="endCalendar"
/>
</div>
</div>
<div
v-else
class="flex justify-center items-center space-x-2 w-full"
>
<InputNumber
v-model="valueStart"
:min="valueStartMin"
:max="valueStartMax"
:maxFractionDigits="2"
inputClass="w-24 text-sm text-right"
@blur="sliderValueRange($event, 'start')"
></InputNumber>
<span class="block px-2">~</span>
<InputNumber
v-model="valueEnd"
:min="valueEndMin"
:max="valueEndMax"
inputClass="w-24 text-sm text-right"
:maxFractionDigits="2"
@blur="sliderValueRange($event, 'end')"
></InputNumber>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/Discover/Map/Filter/Attributes
* Case attribute filter with dynamic form fields
* for filtering by attribute values.
*/
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useAllMapDataStore } from "@/stores/allMapData";
import { setLineChartData } from "@/module/setChartData.js";
import getMoment from "moment";
import InputNumber from "primevue/inputnumber";
import { Decimal } from "decimal.js";
import emitter from "@/utils/emitter";
const emit = defineEmits(["select-attribute"]);
const allMapDataStore = useAllMapDataStore();
const { filterAttrs } = storeToRefs(allMapDataStore);
const selectedAttName = ref({});
const selectedAttRange = ref(null);
const valueTypes = new Set(["int", "float", "date"]);
const classTypes = new Set(["boolean", "string"]);
const chartData = ref({});
const chartOptions = ref({});
const chartComplete = ref(null); // Rendered chart.js instance data
const selectArea = ref(null);
const selectRange = ref(1000); // Number of divisions for the selection range
const startTime = ref(null); // PrimeVue Calendar v-model
const endTime = ref(null); // PrimeVue Calendar v-model
const startMinDate = ref(null);
const startMaxDate = ref(null);
const endMinDate = ref(null);
const endMaxDate = ref(null);
const valueStart = ref(null); // PrimeVue InputNumber v-model
const valueEnd = ref(null); // PrimeVue InputNumber v-model
const valueStartMin = ref(null);
const valueStartMax = ref(null);
const valueEndMin = ref(null);
const valueEndMax = ref(null);
const tableClass =
"w-full h-full !border-separate !border-spacing-x-2 !table-auto text-sm";
const headerModeClass =
"w-8 !p-2 !bg-neutral-10 !border-neutral-500 sticky top-0 left-0 z-10 bg-neutral-10";
const headerClass =
"!bg-neutral-10 !border-neutral-500 !py-2 sticky top-0 left-0 z-10 bg-neutral-10";
const bodyModeClass = "!p-2 !border-0";
const bodyClass = "break-words !py-2 !border-0";
const panelProps = {
onClick: (event) => {
event.stopPropagation();
},
};
const tooltip = {
attributeName: {
value:
"Attributes with too many discrete values are excluded from selection. But users can still view those attributes in the DATA page.",
class: "!max-w-[212px] !text-[10px] !opacity-90",
},
};
const attTotal = computed(() => {
return filterAttrs.value.length;
});
const attRangeTotal = computed(() => {
const type = selectedAttName.value.type;
let result = null; // Initialize the result variable with null
if (classTypes.has(type) && attRangeData.value) {
result = `(${attRangeData.value.length})`; // Assign the length of attRangeData if it exists
}
return result;
});
const attRangeData = computed(() => {
let data = [];
const type = selectedAttName.value.type;
const sum = selectedAttName.value.options
.map((item) => item.freq)
.reduce((acc, cur) => acc + cur, 0);
data = selectedAttName.value.options.map((item, index) => {
const ratio = item.freq / sum;
const result = {
id: index + 1,
key: selectedAttName.value.key,
type: type,
value: item.value,
occ_progress_bar: ratio * 100,
occ_value: item.freq.toLocaleString("en-US"),
occ_ratio: getPercentLabel(ratio),
freq: item.freq,
};
result.label = null;
if (type === "boolean") {
result.label = item.value ? "Yes" : "No";
} else {
result.label = null;
}
return result;
});
return data.sort((x, y) => y.freq - x.freq);
});
// Get the selected Attribute radio's numeric-type data
const valueData = computed(() => {
// filter returns an array, find returns the first matched element, so use find here.
if (valueTypes.has(selectedAttName.value.type)) {
const data = filterAttrs.value.find(
(item) =>
item.type === selectedAttName.value.type &&
item.key === selectedAttName.value.key,
);
return data ?? null;
}
return null;
});
// Compute slider data; time format: millisecond timestamps
const sliderDataComputed = computed(() => {
if (!valueData.value) return [];
let xAxisMin;
let xAxisMax;
const min = valueData.value.min;
const max = valueData.value.max;
const type = valueData.value.type;
switch (type) {
case "dummy":
case "date":
xAxisMin = new Date(min).getTime();
xAxisMax = new Date(max).getTime();
break;
default:
xAxisMin = min;
xAxisMax = max;
break;
}
const range = xAxisMax - xAxisMin;
const step = range / selectRange.value;
let data = [];
for (let i = 0; i <= selectRange.value; i++) {
data.push(xAxisMin + step * i);
}
switch (type) {
case "int":
data = data.map((value) => {
let result = Math.round(value);
result = result === -0 ? 0 : result;
return result;
});
break;
case "float":
data = data.map((value) => {
let result = new Decimal(value.toFixed(2)).toNumber();
result = result === -0 ? 0 : result;
return result;
});
break;
default:
break;
}
return data;
});
// user select value type start and end
const attValueTypeStartEnd = computed(() => {
let start;
let end;
const type = selectedAttName.value.type;
switch (type) {
case "dummy": //sonar-qube
case "date":
start = getMoment(startTime.value).format("YYYY-MM-DDTHH:mm:00");
end = getMoment(endTime.value).format("YYYY-MM-DDTHH:mm:00");
break;
default:
start = valueStart.value;
end = valueEnd.value;
break;
}
const data = {
// Data to send to the backend
type: type,
data: {
key: selectedAttName.value.key,
min: start,
max: end,
},
};
emit("select-attribute", data);
return [start, end];
});
const labelsData = computed(() => {
if (!valueData.value) return [];
const min = new Date(valueData.value.min).getTime();
const max = new Date(valueData.value.max).getTime();
const numPoints = 11;
const step = (max - min) / (numPoints - 1);
const data = [];
for (let i = 0; i < numPoints; i++) {
const x = min + i * step;
data.push(x);
}
return data;
});
/**
* Emits the current attribute selection when a row is selected or deselected.
*/
function onRowSelectionChange() {
const type = selectedAttName.value.type;
const data = {
type: type,
data: selectedAttRange.value,
};
emit("select-attribute", data);
}
/**
* Selects all options in the categorical table.
* @param {Event} e - The input event.
*/
function onRowSelectAll(e) {
selectedAttRange.value = e.data;
const type = selectedAttName.value.type;
const data = {
type: type,
data: selectedAttRange.value,
};
emit("select-attribute", data);
}
/**
* Deselects all options in the categorical table.
*/
function onRowUnelectAll() {
selectedAttRange.value = null;
const type = selectedAttName.value.type;
const data = {
type: type,
data: selectedAttRange.value,
};
emit("select-attribute", data);
}
/**
* Switches the attribute name radio selection.
* @param {Event} e - The input event.
*/
function switchAttNameRadio(e) {
selectedAttRange.value = null;
startTime.value = null;
endTime.value = null;
valueStart.value = null;
valueEnd.value = null;
if (valueData.value) {
// Switch Attribute Name
// Initialize two-way bindings
selectArea.value = [0, selectRange.value];
const min = valueData.value.min;
const max = valueData.value.max;
switch (selectedAttName.value.type) {
case "dummy": //sonar-qube
case "date":
// Clear two-way bindings except for date
valueStart.value = null;
valueEnd.value = null;
// Initialize Calendar
startMinDate.value = new Date(min);
startMaxDate.value = new Date(max);
endMinDate.value = new Date(min);
endMaxDate.value = new Date(max);
// Initialize: set the calendar range to match the timeline range
startTime.value = new Date(min);
endTime.value = new Date(max);
break;
default:
// Clear date two-way bindings
startTime.value = null;
endTime.value = null;
// Initialize InputNumber
valueStartMin.value = min;
valueStartMax.value = max;
valueEndMin.value = min;
valueEndMax.value = max;
// Initialize: set the InputNumber range to match the timeline range
valueStart.value = min;
valueEnd.value = max;
break;
}
// Send to backend
// attValueTypeStartEnd.value; should this function be called? sonar-qube
// Create chart
createChart();
}
}
/**
* set progress bar width
* @param {number} value - The percentage value.
* @returns {string} The CSS width style string.
*/
function progressWidth(value) {
return `width:${value}%;`;
}
/**
* Number to percentage
* @param {number} val - The raw ratio value.
* @returns {string} The formatted percentage string.
*/
function getPercentLabel(val) {
if (Number((val * 100).toFixed(1)) >= 100) return `100%`;
else return `${(val * 100).toFixed(1)}%`;
}
/**
* Adjusts the overlay mask size.
* @param {object} chart - The Chart.js instance data.
*/
function resizeMask(chart) {
if (selectRange.value === 0) return;
const from = (selectArea.value[0] * 0.01) / (selectRange.value * 0.01);
const to = (selectArea.value[1] * 0.01) / (selectRange.value * 0.01);
resizeLeftMask(chart, from);
resizeRightMask(chart, to);
}
/**
* Adjusts the left overlay mask size.
* @param {object} chart - The Chart.js instance data.
*/
function resizeLeftMask(chart, from) {
const canvas = document.querySelector("#chartCanvasId canvas");
const mask = document.getElementById("chart-mask-left");
if (!canvas || !mask) return;
mask.style.left = `${canvas.offsetLeft + chart.chartArea.left}px`;
mask.style.width = `${chart.chartArea.width * from}px`;
mask.style.top = `${canvas.offsetTop + chart.chartArea.top}px`;
mask.style.height = `${chart.chartArea.height}px`;
}
/**
* Adjusts the right overlay mask size.
* @param {object} chart - The Chart.js instance data.
*/
function resizeRightMask(chart, to) {
const canvas = document.querySelector("#chartCanvasId canvas");
const mask = document.getElementById("chart-mask-right");
if (!canvas || !mask) return;
mask.style.left = `${canvas.offsetLeft + chart.chartArea.left + chart.chartArea.width * to}px`;
mask.style.width = `${chart.chartArea.width * (1 - to)}px`;
mask.style.top = `${canvas.offsetTop + chart.chartArea.top}px`;
mask.style.height = `${chart.chartArea.height}px`;
}
/**
* Creates and renders the Chart.js line chart for attribute data.
*/
function createChart() {
const vData = valueData.value;
const max = vData.chart.y_axis.max * 1.1;
const data = setLineChartData(
vData.chart.data,
vData.chart.x_axis.max,
vData.chart.x_axis.min,
);
const isDateType = vData.type === "date";
const minX = vData.chart.x_axis.min;
const maxX = vData.chart.x_axis.max;
let setChartData = {};
let setChartOptions = {};
let setLabels = [];
switch (vData.type) {
case "int":
setLabels = data.map((item) => Math.round(item.x));
break;
case "float":
setLabels = data.map((item, index) => {
let x;
if (index === 0) {
x = Math.floor(item.x * 100) / 100;
} else if (index === data.length - 1) {
item.x = Math.ceil(item.x * 100) / 100;
x = item.x;
} else {
x = Math.round(item.x * 100) / 100;
}
return x;
});
break;
case "date":
setLabels = labelsData.value;
break;
default:
break;
}
setChartData = {
datasets: [
{
label: "Attribute Value",
data: data,
fill: "start",
showLine: false,
tension: 0.4,
backgroundColor: "rgba(0,153,255)",
pointRadius: 0,
},
],
labels: setLabels,
};
setChartOptions = {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 16,
left: 8,
right: 8,
},
},
plugins: {
legend: false, // Hide legend
filler: {
propagate: false,
},
title: false,
},
animation: {
onComplete: (e) => {
chartComplete.value = e.chart;
resizeMask(e.chart);
},
},
interaction: {
intersect: true,
},
scales: {
y: {
beginAtZero: true, // Scale includes 0
max: max,
ticks: {
// Set tick intervals
display: false, // Hide values, only show grid lines
stepSize: max / 4,
},
grid: {
color: "rgba(100,116,139)",
z: 1,
},
border: {
display: false, // Hide the extra border line on the left
},
},
},
};
if (isDateType) {
setChartOptions.scales.x = {
type: "time",
ticks: {
min: minX,
max: maxX,
autoSkip: true, // Automatically determine whether to convert time units
maxRotation: 0, // Do not rotate labels (0~50)
color: "#334155",
display: true,
source: "labels", // Flexibly display label count proportionally
},
grid: {
display: false, // Hide x-axis grid lines
},
time: {
minUnit: "day", // Minimum display unit
// displayFormats: {
// minute: 'HH:mm MMM d',
// hour: 'HH:mm MMM d',
// }
},
};
} else {
setChartOptions.scales.x = {
bounds: "data",
type: "linear",
min: minX,
max: maxX,
ticks: {
autoSkip: true,
maxRotation: 0, // Do not rotate labels (0~50)
color: "#334155",
callback: (value, index, values) => {
let x;
switch (vData.type) {
case "int":
return Math.round(value);
case "float":
switch (index) {
case 0:
x = Math.floor(value * 100) / 100;
break;
case values.length - 1:
x = Math.ceil(value * 100) / 100;
break;
default:
x = Math.round(value * 100) / 100;
}
// Handle scientific notation and other format conversions
// Decimal cannot handle numbers exceeding 16 digits
x = new Intl.NumberFormat(undefined, {
useGrouping: false,
}).format(x);
return x;
}
},
},
grid: {
display: false, // Hide x-axis grid lines
},
};
}
chartData.value = setChartData;
chartOptions.value = setChartOptions;
}
/**
* Handles slider value changes.
* @param {array} e [1, 100]
*/
function changeSelectArea(e) {
// When the calendar changes, the slider follows
const sliderData = sliderDataComputed.value;
const start = sliderData[e[0].toFixed()];
const end = sliderData[e[1].toFixed()]; // Get the index, which must be an integer.
switch (selectedAttName.value.type) {
case "dummy":
case "date":
startTime.value = new Date(start);
endTime.value = new Date(end);
// Reset the start/end calendar selection range
endMinDate.value = new Date(start);
startMaxDate.value = new Date(end);
break;
default:
valueStart.value = start;
valueEnd.value = end;
// Reset the start/end selection range
valueEndMin.value = start;
valueStartMax.value = end;
break;
}
// Recalculate the chart mask
resizeMask(chartComplete.value);
// Execute timeFrameStartEnd to update the data
// attValueTypeStartEnd.value; should this function be called? sonar-qube
}
/**
* Updates the slider and chart when a start or end time is selected.
* @param {object} e - The date object or blur event.
* @param {string} direction start or end
*/
function sliderValueRange(e, direction) {
// Find the closest index; time format: millisecond timestamps
const sliderData = sliderDataComputed.value;
const isDateType = selectedAttName.value.type === "date";
let targetTime = [];
let inputValue;
if (isDateType)
targetTime = [
new Date(attValueTypeStartEnd.value[0]).getTime(),
new Date(attValueTypeStartEnd.value[1]).getTime(),
];
else
targetTime = [attValueTypeStartEnd.value[0], attValueTypeStartEnd.value[1]];
const closestIndexes = targetTime.map((target) => {
let closestIndex = 0;
closestIndex =
((target - sliderData[0]) /
(sliderData[sliderData.length - 1] - sliderData[0])) *
sliderData.length;
let result = Math.round(Math.abs(closestIndex));
result = Math.min(result, selectRange.value);
return result;
});
// Update the slider
selectArea.value = closestIndexes;
// Reset the start/end calendar selection range
if (!isDateType) inputValue = Number(e.value.replaceAll(",", ""));
if (direction === "start") {
if (isDateType) {
endMinDate.value = e;
} else {
valueEndMin.value = inputValue;
}
} else if (direction === "end") {
if (isDateType) {
startMaxDate.value = e;
} else {
valueStartMax.value = inputValue;
}
}
// Recalculate the chart mask
if (!Number.isNaN(closestIndexes[0]) && !Number.isNaN(closestIndexes[1]))
resizeMask(chartComplete.value);
else return;
}
// created() equivalent
emitter.on("map-filter-reset", (value) => {
if (value) {
selectedAttRange.value = null;
if (valueData.value && valueTypes.has(selectedAttName.value.type)) {
const min = valueData.value.min;
const max = valueData.value.max;
startTime.value = new Date(min);
endTime.value = new Date(max);
valueStart.value = min;
valueEnd.value = max;
selectArea.value = [0, selectRange.value];
resizeMask(chartComplete.value);
}
}
});
onMounted(() => {
// Slider
selectArea.value = [0, selectRange.value]; // Initialize the slider
});
onBeforeUnmount(() => {
selectedAttName.value = {};
emitter.off("map-filter-reset");
});
</script>
<style scoped>
@reference "../../../../assets/tailwind.css";
:deep(table tbody td:nth-child(2)) {
@apply whitespace-nowrap break-keep overflow-hidden text-ellipsis max-w-0;
}
</style>

View File

@@ -1,197 +0,0 @@
<template>
<div class="w-full h-full">
<div
class="h-[calc(100%_-_58px)] border-b border-neutral-400 mb-2 scrollbar overflow-x-hidden overflow-y-auto"
>
<div
v-if="this.temporaryData.length === 0"
class="h-full flex justify-center items-center"
>
<span class="text-neutral-500">No Filter.</span>
</div>
<div v-else>
<div class="text-primary h2 flex items-center justify-start my-4">
<span class="material-symbols-outlined m-2">info</span>
<p>Disabled filters will not be saved.</p>
</div>
<Timeline :value="ruleData">
<template #content="rule">
<div
class="border-b border-neutral-300 flex justify-between items-center space-x-2"
>
<!-- content -->
<div class="pl-2 mb-2">
<p class="text-sm font-medium leading-5">
{{ rule.item.type }}:&nbsp;<span class="text-neutral-500">{{
rule.item.label
}}</span>
</p>
</div>
<!-- button -->
<div class="min-w-fit">
<ToggleSwitch
v-model="rule.item.toggle"
@input="isRule($event, rule.index)"
/>
<button
type="button"
class="m-2 focus:ring focus:ring-danger/20 text-neutral-500 hover:text-danger"
@click.stop="deleteRule(rule.index)"
>
<span class="material-symbols-outlined">delete</span>
</button>
</div>
</div>
</template>
</Timeline>
</div>
</div>
<!-- Button -->
<div>
<div class="float-right space-x-4 px-4 py-2">
<button
type="button"
class="btn btn-sm"
:class="[temporaryData.length === 0 ? 'btn-disable' : 'btn-neutral']"
:disabled="temporaryData.length === 0"
@click="deleteRule('all')"
>
Delete All
</button>
<button
type="button"
class="btn btn-sm"
:class="[temporaryData.length === 0 ? 'btn-disable' : 'btn-neutral']"
:disabled="temporaryData.length === 0"
@click="submitAll"
>
Apply All
</button>
</div>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/Map/Filter/Funnel Filter funnel
* panel showing applied filter rules with toggle, delete, and
* apply-all actions.
*/
import { storeToRefs } from "pinia";
import { useToast } from "vue-toast-notification";
import { useLoadingStore } from "@/stores/loading";
import { useAllMapDataStore } from "@/stores/allMapData";
import { delaySecond } from "@/utils/timeUtil.js";
const emit = defineEmits(["submit-all"]);
const $toast = useToast();
const loadingStore = useLoadingStore();
const allMapDataStore = useAllMapDataStore();
const { isLoading } = storeToRefs(loadingStore);
const {
hasResultRule,
temporaryData,
postRuleData,
ruleData,
isRuleData,
tempFilterId,
} = storeToRefs(allMapDataStore);
/**
* Toggles a filter rule on or off.
* @param {boolean} e - Whether the rule is enabled.
* @param {number} index - The rule index.
*/
function isRule(e, index) {
const rule = isRuleData.value[index];
// First get the rule object
// To preserve data order, set the value to 0 and remove it during submitAll
if (e) temporaryData.value[index] = rule;
else temporaryData.value[index] = 0;
}
/**
* Deletes a single filter rule or all rules.
* @param {number|string} index - The rule index, or 'all' to delete all.
*/
async function deleteRule(index) {
if (index === "all") {
temporaryData.value = [];
isRuleData.value = [];
ruleData.value = [];
if (tempFilterId.value) {
isLoading.value = true;
tempFilterId.value = null;
await allMapDataStore.getAllMapData();
await allMapDataStore.getAllTrace(); // SidebarTrace needs to update in sync
emit("submit-all");
isLoading.value = false;
}
$toast.success("Filter(s) deleted.");
} else {
$toast.success(`Filter deleted.`);
temporaryData.value.splice(index, 1);
isRuleData.value.splice(index, 1);
ruleData.value.splice(index, 1);
}
}
/** Submits all enabled filter rules and refreshes the map data. */
async function submitAll() {
postRuleData.value = temporaryData.value.filter((item) => item !== 0); // Get submit data; if toggle buttons are used, find and remove items set to 0
if (!postRuleData.value?.length) return $toast.error("Not selected");
await allMapDataStore.checkHasResult(); // Quick backend check for results
if (hasResultRule.value === null) {
return;
} else if (hasResultRule.value) {
isLoading.value = true;
await allMapDataStore.addTempFilterId();
await allMapDataStore.getAllMapData();
await allMapDataStore.getAllTrace(); // SidebarTrace needs to update in sync
if (temporaryData.value[0]?.type) {
allMapDataStore.traceId = allMapDataStore.traces[0]?.id;
}
emit("submit-all");
isLoading.value = false;
$toast.success("Filter(s) applied.");
return;
}
// sonar-qube "This statement will not be executed conditionally"
isLoading.value = true;
await delaySecond(1);
isLoading.value = false;
$toast.warning("No result.");
}
</script>
<style scoped>
@reference "../../../../assets/tailwind.css";
/* TimeLine */
:deep(.p-timeline) {
@apply leading-none my-4;
}
:deep(.p-timeline-event-opposite) {
@apply hidden;
}
:deep(.p-timeline-event-separator) {
@apply mx-4;
}
:deep(.p-timeline-event-marker) {
@apply !bg-primary !border-primary !w-2 !h-2;
}
:deep(.p-timeline-event-connector) {
@apply !bg-primary my-2 !w-[1px];
}
:deep(.p-timeline-event-content) {
@apply !px-0;
}
</style>

View File

@@ -1,419 +0,0 @@
<template>
<div
class="bg-neutral-10 border border-neutral-300 rounded-xl px-4 w-full h-full"
>
<section class="pt-2 pb-20 space-y-2 text-sm min-w-[48%] h-full">
<p class="h2">Range Selection</p>
<div class="text-primary h2 flex items-center justify-start">
<span class="material-symbols-outlined mr-2 !text-base">info</span>
<p>Select or fill in a time range.</p>
</div>
<div class="chartContainer h-3/5 relative">
<canvas id="chartCanvasId"></canvas>
<div id="chart-mask-left" class="absolute bg-neutral-10/50"></div>
<div id="chart-mask-right" class="absolute bg-neutral-10/50"></div>
</div>
<div class="px-2 py-3">
<Slider
v-model="selectArea"
:step="1"
:min="0"
:max="selectRange"
range
class="mx-2"
@change="changeSelectArea($event)"
/>
</div>
<!-- Calendar group -->
<div class="flex justify-center items-center space-x-2 w-full">
<div>
<span class="block mb-2">Start time</span>
<DatePicker
v-model="startTime"
dateFormat="yy/mm/dd"
:panelProps="panelProps"
:minDate="startMinDate"
:maxDate="startMaxDate"
showTime
showIcon
hourFormat="24"
@date-select="sliderTimeRange($event, 'start')"
id="startCalendar"
/>
</div>
<span class="block mt-4">~</span>
<div>
<span class="block mb-2">End time</span>
<DatePicker
v-model="endTime"
dateFormat="yy/mm/dd"
:panelProps="panelProps"
:minDate="endMinDate"
:maxDate="endMaxDate"
showTime
showIcon
hourFormat="24"
@date-select="sliderTimeRange($event, 'end')"
id="endCalendar"
/>
</div>
</div>
<!-- End calendar group -->
</section>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/Map/Filter/Timeframes
* Timeframe filter with date range pickers and
* duration range selectors.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useAllMapDataStore } from "@/stores/allMapData";
import { Chart, registerables } from "chart.js";
import "chartjs-adapter-moment";
import getMoment from "moment";
const props = defineProps(["selectValue"]);
const allMapDataStore = useAllMapDataStore();
const { filterTimeframe, selectTimeFrame } = storeToRefs(allMapDataStore);
const selectRange = ref(1000); // Number of divisions for the selection range
const selectArea = ref(null);
const chart = ref(null);
const canvasId = ref(null);
const startTime = ref(null);
const endTime = ref(null);
const startMinDate = ref(null);
const startMaxDate = ref(null);
const endMinDate = ref(null);
const endMaxDate = ref(null);
const panelProps = ref({
onClick: (event) => {
event.stopPropagation();
},
});
// user select time start and end
const timeFrameStartEnd = computed(() => {
if (!startTime.value || !endTime.value) {
return [];
}
const start = getMoment(startTime.value).format("YYYY-MM-DDTHH:mm:00");
const end = getMoment(endTime.value).format("YYYY-MM-DDTHH:mm:00");
return [start, end];
});
watch(timeFrameStartEnd, (newValue) => {
if (newValue.length === 2) {
selectTimeFrame.value = newValue; // Data to send to the backend
}
});
// Compute slider data; time format: millisecond timestamps
const sliderData = computed(() => {
const xAxisMin = new Date(filterTimeframe.value.x_axis.min).getTime();
const xAxisMax = new Date(filterTimeframe.value.x_axis.max).getTime();
const range = xAxisMax - xAxisMin;
const step = range / selectRange.value;
const data = [];
for (let i = 0; i <= selectRange.value; i++) {
data.push(xAxisMin + step * i);
}
return data;
});
// Add the minimum and maximum values
const timeFrameData = computed(() => {
if (!filterTimeframe.value?.data || filterTimeframe.value.data.length < 10)
return [];
const data = filterTimeframe.value.data.map((i) => ({ x: i.x, y: i.y }));
// See ./public/timeFrameSlope for the y-axis slope calculation diagram
// x values are 0 ~ 11,
// Name three coordinates (ax, ay), (bx, by), (cx, cy) as (a, b), (c, d), (e, f)
// Minimum: (f - b)(c - a) = (e - a)(d - b), solve for b = (ed - ad - fa - fc) / (e - c - a)
// Maximum: (f - b)(e - c) = (f - d)(e - a), solve for f = (be - bc - de + da) / (a - c)
// Y-axis minimum value
const a = 0;
let b;
const c = 1;
const d = filterTimeframe.value.data[0].y;
const e = 2;
const f = filterTimeframe.value.data[1].y;
b = (e * d - a * d - f * a - f * c) / (e - c - a);
if (b < 0) {
b = 0;
}
// Y-axis maximum value
const ma = 9;
const mb = filterTimeframe.value.data[8].y;
const mc = 10;
const md = filterTimeframe.value.data[9].y;
const me = 11;
let mf = (mb * me - mb * mc - md * me + md * ma) / (ma - mc);
if (mf < 0) {
mf = 0;
}
// Add the minimum value
data.unshift({
x: filterTimeframe.value.x_axis.min_base,
y: b,
});
// Add the maximum value
data.push({
x: filterTimeframe.value.x_axis.max_base,
y: mf,
});
return data;
});
const labelsData = computed(() => {
const min = new Date(filterTimeframe.value.x_axis.min_base).getTime();
const max = new Date(filterTimeframe.value.x_axis.max_base).getTime();
const numPoints = 11;
const step = (max - min) / (numPoints - 1);
const data = [];
for (let i = 0; i < numPoints; i++) {
const x = min + i * step;
data.push(x);
}
return data;
});
watch(selectTimeFrame, (newValue, oldValue) => {
if (newValue.length === 0) {
startTime.value = new Date(filterTimeframe.value.x_axis.min);
endTime.value = new Date(filterTimeframe.value.x_axis.max);
selectArea.value = [0, selectRange.value];
resizeMask(chart.value);
}
});
/**
* Adjusts the overlay mask size.
* @param {object} chartInstance - The Chart.js instance.
*/
function resizeMask(chartInstance) {
const from = (selectArea.value[0] * 0.01) / (selectRange.value * 0.01);
const to = (selectArea.value[1] * 0.01) / (selectRange.value * 0.01);
if (props.selectValue[0] === "Timeframes") {
resizeLeftMask(chartInstance, from);
resizeRightMask(chartInstance, to);
}
}
/**
* Adjusts the left overlay mask size.
* @param {object} chartInstance - The Chart.js instance.
*/
function resizeLeftMask(chartInstance, from) {
const canvas = document.getElementById("chartCanvasId");
const mask = document.getElementById("chart-mask-left");
if (!canvas || !mask) return;
mask.style.left = `${canvas.offsetLeft + chartInstance.chartArea.left}px`;
mask.style.width = `${chartInstance.chartArea.width * from}px`;
mask.style.top = `${canvas.offsetTop + chartInstance.chartArea.top}px`;
mask.style.height = `${chartInstance.chartArea.height}px`;
}
/**
* Adjusts the right overlay mask size.
* @param {object} chartInstance - The Chart.js instance.
*/
function resizeRightMask(chartInstance, to) {
const canvas = document.getElementById("chartCanvasId");
const mask = document.getElementById("chart-mask-right");
if (!canvas || !mask) return;
mask.style.left = `${canvas.offsetLeft + chartInstance.chartArea.left + chartInstance.chartArea.width * to}px`;
mask.style.width = `${chartInstance.chartArea.width * (1 - to)}px`;
mask.style.top = `${canvas.offsetTop + chartInstance.chartArea.top}px`;
mask.style.height = `${chartInstance.chartArea.height}px`;
}
/**
* Creates and renders the Chart.js area chart for timeframe data.
*/
function createChart() {
const max = filterTimeframe.value.y_axis.max * 1.1;
const minX = timeFrameData.value[0]?.x;
const maxX = timeFrameData.value[timeFrameData.value.length - 1]?.x;
const data = {
labels: labelsData.value,
datasets: [
{
label: "Case",
data: timeFrameData.value,
fill: "start",
showLine: false,
tension: 0.4,
backgroundColor: "rgba(0,153,255)",
pointRadius: 0,
x: "x",
y: "y",
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 16,
left: 8,
right: 8,
},
},
plugins: {
legend: false, // Hide legend
filler: {
propagate: false,
},
title: false,
},
// animations: false, // Disable animations
animation: {
onComplete: (e) => {
resizeMask(e.chart);
},
},
interaction: {
intersect: true,
},
scales: {
x: {
type: "time",
min: minX,
max: maxX,
ticks: {
autoSkip: true,
maxRotation: 0, // Do not rotate labels (0~50)
color: "#334155",
display: true,
source: "labels",
},
grid: {
display: false, // Hide x-axis grid lines
},
time: {
minUnit: "day", // Minimum display unit
// displayFormats: {
// minute: 'HH:mm MMM d',
// hour: 'HH:mm MMM d',
// }
},
},
y: {
beginAtZero: true, // Scale includes 0
max: max,
ticks: {
// Set tick intervals
display: false, // Hide values, only show grid lines
stepSize: max / 4,
},
grid: {
color: "rgba(100,116,139)",
z: 1,
},
border: {
display: false, // Hide the extra border line on the left
},
},
},
};
const config = {
type: "line",
data: data,
options: options,
};
canvasId.value = document.getElementById("chartCanvasId");
chart.value = new Chart(canvasId.value, config);
}
/**
* Handles slider value changes.
* @param {array} e [1, 100]
*/
function changeSelectArea(e) {
// When the calendar changes, the slider follows
const sliderDataVal = sliderData.value;
const start = sliderDataVal[e[0].toFixed()];
const end = sliderDataVal[e[1].toFixed()]; // Get the index, which must be an integer.
startTime.value = new Date(start);
endTime.value = new Date(end);
// Reset the start/end calendar selection range
endMinDate.value = new Date(start);
startMaxDate.value = new Date(end);
// Recalculate the chart mask
resizeMask(chart.value);
}
/**
* Updates the slider and chart when a start or end time is selected.
* @param {object} e - The selected date object.
* @param {string} direction start or end
*/
function sliderTimeRange(e, direction) {
// Find the closest index; time format: millisecond timestamps
const sliderDataVal = sliderData.value;
const targetTime = [
new Date(timeFrameStartEnd.value[0]).getTime(),
new Date(timeFrameStartEnd.value[1]).getTime(),
];
const closestIndexes = targetTime.map((target) => {
let closestIndex = 0;
closestIndex =
((target - sliderDataVal[0]) /
(sliderDataVal[sliderDataVal.length - 1] - sliderDataVal[0])) *
sliderDataVal.length;
let result = Math.round(Math.abs(closestIndex));
result = Math.min(result, selectRange.value);
return result;
});
// Update the slider
selectArea.value = closestIndexes;
// Reset the start/end calendar selection range
if (direction === "start") endMinDate.value = e;
else if (direction === "end") startMaxDate.value = e;
// Recalculate the chart mask
if (!Number.isNaN(closestIndexes[0]) && !Number.isNaN(closestIndexes[1]))
resizeMask(chart.value);
else return;
}
onMounted(() => {
// Chart.js
Chart.register(...registerables);
createChart();
// Slider
selectArea.value = [0, selectRange.value];
// Calendar
startMinDate.value = new Date(filterTimeframe.value.x_axis.min);
startMaxDate.value = new Date(filterTimeframe.value.x_axis.max);
endMinDate.value = new Date(filterTimeframe.value.x_axis.min);
endMaxDate.value = new Date(filterTimeframe.value.x_axis.max);
// Set the calendar range to match the timeline range
startTime.value = startMinDate.value;
endTime.value = startMaxDate.value;
});
onBeforeUnmount(() => {
chart.value?.destroy();
chart.value = null;
});
</script>

View File

@@ -1,503 +0,0 @@
<template>
<div
class="flex justify-between items-start bg-neutral-10 border border-neutral-300 rounded-xl px-4 w-full h-full space-x-4 overflow-y-auto overflow-x-auto scrollbar"
>
<!-- Range Selection -->
<section class="py-2 space-y-2 text-sm min-w-[48%] h-full">
<p class="h2">Range Selection</p>
<div class="text-primary h2 flex items-center justify-start">
<span class="material-symbols-outlined mr-2 !text-base">info</span>
<p>Select a percentage range.</p>
</div>
<Chart
type="bar"
:data="chartData"
:options="chartOptions"
class="h-2/5"
/>
<div class="px-2">
<p class="py-4">
Select percentage of case
<span class="float-right">{{ caseTotalPercent }}%</span>
</p>
<Slider
v-model="selectArea"
:step="1"
:min="0"
:max="traceTotal"
range
class="mx-2"
/>
</div>
</section>
<!-- Trace List -->
<section class="h-full min-w-[48%] py-2 space-y-2">
<p class="h2">Trace List ({{ traceTotal }})</p>
<p class="text-primary h2 flex items-center justify-start">
<span class="material-symbols-outlined mr-2 !text-base">info</span>Click
trace number to see more.
</p>
<div
class="overflow-y-scroll overflow-x-hidden scrollbar mx-[-8px] max-h-[calc(100%_-_96px)]"
>
<table class="border-separate border-spacing-x-2 text-sm w-full">
<caption class="hidden">
Trace list
</caption>
<thead class="sticky top-0 z-10 bg-neutral-10">
<tr>
<th class="h2 px-2 border-b border-neutral-500">Trace</th>
<th
class="h2 px-2 border-b border-neutral-500 text-start"
colspan="3"
>
Occurrences
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(trace, key) in traceList"
:key="key"
class="cursor-pointer hover:text-primary"
@click="switchCaseData(trace.id, trace.base_count)"
>
<td class="p-2 text-center">#{{ trace.id }}</td>
<td class="p-2 min-w-[96px]">
<div
class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden"
>
<div class="h-full bg-primary" :style="trace.value"></div>
</div>
</td>
<td class="py-2 text-right">{{ trace.count }}</td>
<td class="p-2 text-right">{{ trace.ratio }}%</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Trace item Table -->
<section v-show="showTraceId" class="pl-4 h-full min-w-full py-2 space-y-2">
<p class="h2 mb-2">Trace #{{ showTraceId }}</p>
<div class="h-36 w-full px-2 mb-2 border border-neutral-300 rounded">
<div class="h-full w-full">
<div
id="cyTrace"
ref="cyTraceRef"
class="h-full min-w-full relative"
></div>
</div>
</div>
<div
class="overflow-y-auto overflow-x-auto scrollbar h-[calc(100%_-_200px)] infiniteTable"
@scroll="handleScroll"
>
<DataTable
:value="caseData"
showGridlines
tableClass="text-sm"
breakpoint="0"
>
<div v-for="col in columnData" :key="col.field">
<Column :field="col.field" :header="col.header">
<template #body="{ data }">
<div
:class="
data[col.field]?.length > 18
? 'whitespace-normal'
: 'whitespace-nowrap'
"
>
{{ data[col.field] }}
</div>
</template>
</Column>
</div>
</DataTable>
</div>
</section>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/Map/Filter/Trace
* Trace filter with trace selection table and
* trace detail display.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useAllMapDataStore } from "@/stores/allMapData";
import { useLoadingStore } from "@/stores/loading";
import cytoscapeMapTrace from "@/module/cytoscapeMapTrace.js";
import { cloneDeep } from "lodash-es";
const emit = defineEmits(["filter-trace-selectArea"]);
const allMapDataStore = useAllMapDataStore();
const loadingStore = useLoadingStore();
const {
infinit404,
baseInfiniteStart,
baseTraces,
baseTraceTaskSeq,
baseCases,
} = storeToRefs(allMapDataStore);
const { isLoading } = storeToRefs(loadingStore);
const processMap = ref({
nodes: [],
edges: [],
});
const showTraceId = ref(null);
const infinitMaxItems = ref(false);
const infiniteData = ref([]);
const infiniteFinish = ref(true); // Whether infinite scroll loading is complete
const chartOptions = ref(null);
const selectArea = ref([0, 1]);
const cyTraceRef = ref(null);
const cyTraceInstance = ref(null);
const traceTotal = computed(() => {
return baseTraces.value.length;
});
defineExpose({ selectArea, showTraceId, traceTotal });
const traceCountTotal = computed(() => {
return baseTraces.value
.map((trace) => trace.count)
.reduce((acc, cur) => acc + cur, 0);
});
const traceList = computed(() => {
if (traceCountTotal.value === 0) return [];
return baseTraces.value
.map((trace) => {
return {
id: trace.id,
value: progressWidth(
Number(((trace.count / traceCountTotal.value) * 100).toFixed(1)),
),
count: trace.count.toLocaleString("en-US"),
base_count: trace.count,
ratio: getPercentLabel(trace.count / traceCountTotal.value),
};
})
.slice(selectArea.value[0], selectArea.value[1]);
});
const caseTotalPercent = computed(() => {
if (traceCountTotal.value === 0) return "0%";
const ratioSum =
traceList.value
.map((trace) => trace.base_count)
.reduce((acc, cur) => acc + cur, 0) / traceCountTotal.value;
return getPercentLabel(ratioSum);
});
const chartData = computed(() => {
const start = selectArea.value[0];
const end = selectArea.value[1] - 1;
const labels = baseTraces.value.map((trace) => `#${trace.id}`);
const data = baseTraces.value.map((trace) =>
getPercentLabel(trace.count / traceCountTotal.value),
);
const selectAreaData = baseTraces.value.map((trace, index) =>
index >= start && index <= end ? "rgba(0,153,255)" : "rgba(203, 213, 225)",
);
return {
// Data to display
labels,
datasets: [
{
label: "Trace", // Dataset label
data,
backgroundColor: selectAreaData,
categoryPercentage: 1,
barPercentage: 1,
},
],
};
});
const caseData = computed(() => {
const data = cloneDeep(infiniteData.value);
data.forEach((item) => {
item.attributes.forEach((attribute, index) => {
item[`att_${index}`] = attribute.value; // Create a new key-value pair
});
delete item.attributes; // Remove the original attributes property
});
return data;
});
const columnData = computed(() => {
const data = cloneDeep(baseCases.value);
let result = [
{ field: "id", header: "Case Id" },
{ field: "started_at", header: "Start time" },
{ field: "completed_at", header: "End time" },
];
if (data.length !== 0) {
result = [
{ field: "id", header: "Case Id" },
{ field: "started_at", header: "Start time" },
{ field: "completed_at", header: "End time" },
...(data[0]?.attributes ?? []).map((att, index) => ({
field: `att_${index}`,
header: att.key,
})),
];
}
return result;
});
watch(selectArea, (newValue, oldValue) => {
const roundValue = Math.round(newValue[1].toFixed());
if (newValue[1] !== roundValue) selectArea.value[1] = roundValue;
if (newValue != oldValue) emit("filter-trace-selectArea", newValue); // Determine whether Apply should be disabled
});
watch(infinit404, (newValue) => {
if (newValue === 404) infinitMaxItems.value = true;
});
watch(showTraceId, (newValue, oldValue) => {
const isScrollTop = document.querySelector(".infiniteTable");
if (isScrollTop && isScrollTop.scrollTop !== undefined)
if (newValue !== oldValue) isScrollTop.scrollTop = 0;
});
/**
* Set bar chart Options
*/
function barOptions() {
return {
maintainAspectRatio: false,
aspectRatio: 0.8,
layout: {
padding: {
top: 16,
left: 8,
right: 8,
},
},
plugins: {
legend: {
// Legend
display: false,
},
tooltip: {
callbacks: {
label: (tooltipItems) => {
return `${tooltipItems.dataset.label}: ${tooltipItems.parsed.y}%`;
},
},
},
},
animations: false,
scales: {
x: {
display: false,
},
y: {
ticks: {
// Set tick intervals
display: false, // Hide values, only show grid lines
min: 0,
max: traceList.value[0]?.ratio ?? 1,
stepSize: (traceList.value[0]?.ratio ?? 1) / 4,
},
grid: {
color: "rgba(100,116,139)",
z: 1,
},
border: {
display: false, // Hide the extra border line on the left
},
},
},
};
}
/**
* Number to percentage
* @param {number} val - The raw ratio value.
* @returns {string} The formatted percentage string.
*/
function getPercentLabel(val) {
if (Number((val * 100).toFixed(1)) >= 100) return 100;
else return Number.parseFloat((val * 100).toFixed(1));
}
/**
* set progress bar width
* @param {number} value - The percentage value.
* @returns {string} The CSS width style string.
*/
function progressWidth(value) {
return `width:${value}%;`;
}
/**
* switch case data
* @param {number} id case id
* @param {number} count - The total number of cases.
*/
async function switchCaseData(id, count) {
// Do nothing if clicking the same id
if (id === showTraceId.value) return;
isLoading.value = true; // Always show loading screen
try {
infinit404.value = null;
infinitMaxItems.value = false;
baseInfiniteStart.value = 0;
allMapDataStore.baseTraceId = id;
infiniteData.value = await allMapDataStore.getBaseTraceDetail();
showTraceId.value = id; // Set after getDetail so the case table finishes loading before switching showTraceId
createCy();
} catch (error) {
console.error("Failed to load data:", error);
} finally {
isLoading.value = false;
}
}
/**
* Assembles the trace element nodes data for Cytoscape rendering.
*/
function setNodesData() {
// Clear nodes to prevent accumulation on each render
processMap.value.nodes = [];
// Populate nodes with data returned from the API call
baseTraceTaskSeq.value.forEach((node, index) => {
processMap.value.nodes.push({
data: {
id: index,
label: node,
backgroundColor: "#CCE5FF",
bordercolor: "#003366",
shape: "round-rectangle",
height: 80,
width: 100,
},
});
});
}
/**
* Assembles the trace edge line data for Cytoscape rendering.
*/
function setEdgesData() {
processMap.value.edges = [];
baseTraceTaskSeq.value.forEach((edge, index) => {
processMap.value.edges.push({
data: {
source: `${index}`,
target: `${index + 1}`,
lineWidth: 1,
style: "solid",
},
});
});
// The number of edges is one less than the number of nodes
processMap.value.edges.pop();
}
/**
* create trace cytoscape's map
*/
function createCy() {
const graphId = cyTraceRef.value;
setNodesData();
setEdgesData();
cyTraceInstance.value?.destroy();
cyTraceInstance.value = cytoscapeMapTrace(processMap.value.nodes, processMap.value.edges, graphId);
}
/**
* Infinite scroll: listens for scroll reaching the bottom.
* @param {Event} event - The scroll event.
*/
function handleScroll(event) {
if (
infinitMaxItems.value ||
baseCases.value.length < 20 ||
infiniteFinish.value === false
)
return;
const container = event.target;
const overScrollHeight =
container.scrollTop + container.clientHeight >= container.scrollHeight;
if (overScrollHeight) fetchData();
}
/**
* Infinite scroll: loads more data when the bottom is reached.
*/
async function fetchData() {
try {
isLoading.value = true;
infiniteFinish.value = false;
baseInfiniteStart.value += 20;
await allMapDataStore.getBaseTraceDetail();
infiniteData.value = [...infiniteData.value, ...baseCases.value];
infiniteFinish.value = true;
isLoading.value = false;
} catch (error) {
console.error("Failed to load data:", error);
infiniteFinish.value = true;
isLoading.value = false;
}
}
onMounted(() => {
isLoading.value = true; // Will be closed after createCy finishes
setNodesData();
setEdgesData();
createCy();
chartOptions.value = barOptions();
selectArea.value = [0, traceTotal.value];
isLoading.value = false;
});
onBeforeUnmount(() => {
cyTraceInstance.value?.destroy();
});
</script>
<style scoped>
@reference "../../../../assets/tailwind.css";
/* Table set */
:deep(.p-datatable-thead) {
@apply sticky top-0 left-0 z-10 bg-neutral-10;
}
:deep(.p-datatable .p-datatable-thead > tr > th) {
@apply !border-y-0 border-neutral-500 bg-neutral-100 after:absolute after:left-0 after:w-full after:h-full after:block after:top-0 after:border-b after:border-t after:border-neutral-500;
white-space: nowrap;
}
:deep(.p-datatable .p-datatable-tbody > tr > td) {
@apply border-neutral-500 !border-t-0 text-center;
}
:deep(.p-datatable.p-datatable-gridlines .p-datatable-tbody > tr > td) {
min-width: 72px;
max-width: 184px;
overflow-wrap: break-word;
word-wrap: break-word;
}
/* Center datatable header */
:deep(.p-column-header-content) {
@apply justify-center;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,592 +0,0 @@
<template>
<Drawer
:visible="sidebarState"
:closeIcon="'pi pi-angle-right'"
:modal="false"
position="right"
:dismissable="false"
class="!w-[360px]"
@hide="hide"
@show="show"
>
<template #header>
<ul class="flex space-x-4 pl-4">
<li
class="h1 border-r-2 border-neutral-300 pr-4 cursor-pointer hover:text-neutral-900 hover:duration-700"
@click="switchTab('summary')"
:class="tab === 'summary' ? 'text-neutral-900' : ''"
>
Summary
</li>
<li
class="h1 border-r-2 border-neutral-300 pr-4 cursor-pointer hover:text-neutral-900 hover:duration-700"
@click="switchTab('insight')"
:class="tab === 'insight' ? 'text-neutral-900' : ''"
>
Insight
</li>
</ul>
</template>
<!-- header: summary -->
<div v-if="tab === 'summary'">
<!-- Stats -->
<ul class="pb-4 border-b border-neutral-300">
<li>
<p class="h2">{{ i18next.t("Map.FileName") }}</p>
<div class="flex items-center">
<div class="blue-dot w-3 h-3 bg-[#0099FF] rounded-full mr-2"></div>
<span>{{ currentMapFile }}</span>
</div>
</li>
</ul>
<ul class="pb-4 border-b border-neutral-300">
<li>
<p class="h2">Cases</p>
<div class="flex justify-between items-center">
<div class="w-full mr-8">
<span class="block text-[12px]"
>{{ stats.cases.count.toLocaleString("en-US") }} /
{{ stats.cases.total.toLocaleString("en-US") }}</span
>
<ProgressBar
:value="valueCases"
:showValue="false"
class="!h-2 !rounded-full my-1 !bg-neutral-300"
></ProgressBar>
</div>
<span
class="block text-primary text-[20px] text-right font-medium basis-28"
>{{ getPercentLabel(stats.cases.ratio) }}</span
>
</div>
</li>
<li>
<p class="h2">Traces</p>
<div class="flex justify-between items-center">
<div class="w-full mr-8">
<span class="block text-[12px]"
>{{ stats.traces.count.toLocaleString("en-US") }} /
{{ stats.traces.total.toLocaleString("en-US") }}</span
>
<ProgressBar
:value="valueTraces"
:showValue="false"
class="!h-2 !rounded-full my-1 !bg-neutral-300"
></ProgressBar>
</div>
<span
class="block text-primary text-[20px] text-right font-medium basis-28"
>{{ getPercentLabel(stats.traces.ratio) }}</span
>
</div>
</li>
<li>
<p class="h2">Activity Instances</p>
<div class="flex justify-between items-center">
<div class="w-full mr-8">
<span class="block text-[12px]"
>{{ stats.task_instances.count.toLocaleString("en-US") }} /
{{ stats.task_instances.total.toLocaleString("en-US") }}</span
>
<ProgressBar
:value="valueTaskInstances"
:showValue="false"
class="!h-2 !rounded-full my-1 !bg-neutral-300"
></ProgressBar>
</div>
<span
class="block text-primary text-[20px] text-right font-medium basis-28"
>{{ getPercentLabel(stats.task_instances.ratio) }}</span
>
</div>
</li>
<li>
<p class="h2">Activities</p>
<div class="flex justify-between items-center">
<div class="w-full mr-8">
<span class="block text-[12px]"
>{{ stats.tasks.count.toLocaleString("en-US") }} /
{{ stats.tasks.total.toLocaleString("en-US") }}</span
>
<ProgressBar
:value="valueTasks"
:showValue="false"
class="!h-2 !rounded-full my-1 !bg-neutral-300"
></ProgressBar>
</div>
<span
class="block text-primary text-[20px] text-right font-medium basis-28"
>{{ getPercentLabel(stats.tasks.ratio) }}</span
>
</div>
</li>
</ul>
<!-- Log Timeframe -->
<div class="pt-1 pb-4 border-b border-neutral-300">
<p class="h2">Log Timeframe</p>
<div class="text-sm flex items-center">
<div
class="blue-dot w-3 h-3 bg-[#0099FF] rounded-full mr-2 flex"
></div>
<span class="pr-1 flex">{{ moment(stats.started_at) }}</span>
~
<span class="pl-1 flex">{{ moment(stats.completed_at) }}</span>
</div>
</div>
<!-- Case Duration -->
<div class="pt-1 pb-4">
<p class="h2">Case Duration</p>
<table class="text-sm caseDurationTable">
<caption class="hidden">
Case Duration
</caption>
<th class="hidden"></th>
<tbody>
<tr>
<td>
<Tag
value="MIN"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag>
</td>
<td class="text-[#0099FF] flex w-20 justify-end">
{{
timeLabel(stats.case_duration.min)[1] +
" " +
timeLabel(stats.case_duration.min)[2]
}}
</td>
</tr>
<tr>
<td>
<Tag
value="AVG"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag>
</td>
<td class="text-[#0099FF] flex w-20 justify-end">
{{
timeLabel(stats.case_duration.average)[1] +
" " +
timeLabel(stats.case_duration.average)[2]
}}
</td>
</tr>
<tr>
<td>
<Tag
value="MED"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag>
</td>
<td class="text-[#0099FF] flex w-20 justify-end">
{{
timeLabel(stats.case_duration.median)[1] +
" " +
timeLabel(stats.case_duration.median)[2]
}}
</td>
</tr>
<tr>
<td>
<Tag
value="MAX"
class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"
></Tag>
</td>
<td class="text-[#0099FF] flex w-20 justify-end">
{{
timeLabel(stats.case_duration.max)[1] +
" " +
timeLabel(stats.case_duration.max)[2]
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- header: insight -->
<div v-if="tab === 'insight'">
<div class="border-b-2 border-neutral-300 mb-4">
<p class="h2">Most Frequent</p>
<ul class="list-disc ml-6 mb-2 text-sm">
<li class="leading-5">
Activity:&nbsp;
<span
class="text-[#0099FF] break-words bg-[#F1F5F9] px-2 rounded mx-1"
v-for="(value, key) in insights.most_freq_tasks"
:key="key"
>{{ value }}</span
>
</li>
<li class="leading-5">
Inbound connections:&nbsp;
<span
class="text-[#0099FF] break-words bg-[#F1F5F9] px-2 rounded mx-1"
v-for="(value, key) in insights.most_freq_in"
:key="key"
>{{ value }}
</span>
</li>
<li class="leading-5">
Outbound connections:&nbsp;
<span
class="text-[#0099FF] break-words bg-[#F1F5F9] px-2 rounded mx-1"
v-for="(value, key) in insights.most_freq_out"
:key="key"
>{{ value }}
</span>
</li>
</ul>
<p class="h2">Most Time-Consuming</p>
<ul class="list-disc ml-6 mb-4 text-sm">
<li class="w-full leading-5">
Activity:&nbsp;
<span
class="text-primary break-words bg-[#F1F5F9] px-2 rounded mx-1"
v-for="(value, key) in insights.most_time_tasks"
:key="key"
>{{ value }}
</span>
</li>
<li class="w-full leading-5 mt-2">
Connection:&nbsp;
<span
class="text-primary break-words"
v-for="(item, key) in insights.most_time_edges"
:key="key"
>
<span v-for="(value, index) in item" :key="index">
<span class="connection-text bg-[#F1F5F9] px-2 rounded">{{
value
}}</span>
<span v-if="index !== item.length - 1"
>&nbsp;
<span class="material-symbols-outlined !text-lg align-sub"
>arrow_forward</span
>
&nbsp;</span
>
</span>
<span
v-if="key !== insights.most_time_edges.length - 1"
class="text-neutral-900"
>,&nbsp;</span
>
</span>
</li>
</ul>
</div>
<div>
<ul
class="trace-buttons text-neutral-500 grid grid-cols-2 gap-2 text-center text-sm font-medium mb-2"
>
<li
class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500"
@click="onActiveTraceClick(0)"
:class="activeTrace === 0 ? 'text-primary border-primary' : ''"
>
Self-Loop
</li>
<li
class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500"
@click="onActiveTraceClick(1)"
:class="activeTrace === 1 ? 'text-primary border-primary' : ''"
>
Short-Loop
</li>
<li
class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500"
@click="onActiveTraceClick(2)"
:class="activeTrace === 2 ? 'text-primary border-primary' : ''"
>
Shortest Trace
</li>
<li
class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500"
@click="onActiveTraceClick(3)"
:class="activeTrace === 3 ? 'text-primary border-primary' : ''"
>
Longest Trace
</li>
<li
class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500"
@click="onActiveTraceClick(4)"
:class="activeTrace === 4 ? 'text-primary border-primary' : ''"
>
Most Frequent Trace
</li>
</ul>
<div
class="reset-trace-button underline text-[#4E5969] text-[14px] flex justify-end cursor-pointer font-semibold"
@click="onResetTraceBtnClick"
>
{{ i18next.t("Map.Reset") }}
</div>
<div>
<Tabs v-model:value="activeTrace">
<TabPanel :value="0" header="Self-loop" contentClass="text-sm">
<p v-if="insights.self_loops.length === 0">No data</p>
<ul v-else class="ml-6 space-y-1">
<li v-for="(value, key) in insights.self_loops" :key="key">
<span>{{ value }}</span>
</li>
</ul>
</TabPanel>
<TabPanel :value="1" header="Short-loop" contentClass="text-sm">
<p v-if="insights.short_loops.length === 0">No data</p>
<ul v-else class="ml-6 space-y-1">
<li
class="break-words"
v-for="(item, key) in insights.short_loops"
:key="key"
>
<span v-for="(value, index) in item" :key="index">
{{ value }}
<span v-if="index !== item.length - 1"
>&nbsp;
<span class="material-symbols-outlined !text-lg align-sub"
>sync_alt</span
>
&nbsp;</span
>
</span>
</li>
</ul>
</TabPanel>
<!-- Iterate starting from shortest_traces -->
<TabPanel
v-for="([field, label], i) in fieldNamesAndLabelNames"
:key="i"
:value="i + 2"
:header="label"
contentClass="text-sm"
>
<p
v-if="insights[field].length === 0"
class="bg-neutral-100 p-2 rounded"
>
No data
</p>
<ul v-else class="ml-1 space-y-1">
<li
v-for="(item, key2) in insights[field]"
:key="key2"
class="mb-2 flex bg-neutral-100 p-2 rounded"
>
<div class="flex left-col mr-1">
<input
type="radio"
name="customRadio"
:value="key2"
v-model="clickedPathListIndex"
class="hidden peer"
@click="onPathOptionClick(key2)"
/>
<!-- If in BPMN view mode, path highlighting is not allowed -->
<span
v-if="!isBPMNOn"
@click="onPathOptionClick(key2)"
:class="[
'w-[18px] h-[18px] rounded-full border-2 inline-flex items-center justify-center cursor-pointer bg-[#FFFFFF]',
clickedPathListIndex === key2
? 'border-[#0099FF]'
: 'border-[#CBD5E1]',
]"
>
<div
:class="[
'w-[9px] h-[9px] rounded-full transition-opacity cursor-pointer',
clickedPathListIndex === key2
? 'bg-[#0099FF]'
: 'opacity-0',
]"
></div>
</span>
</div>
<div class="right-col">
<span v-for="(value, index) in item" :key="index">
{{ value }}
<span v-if="index !== item.length - 1">
&nbsp;
<span
class="material-symbols-outlined !text-lg align-sub"
>arrow_forward</span
>
&nbsp;
</span>
</span>
</div>
</li>
</ul>
</TabPanel>
</Tabs>
</div>
</div>
</div>
</Drawer>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/Map/SidebarState
* Summary statistics sidebar for the Map view
* displaying cases, traces, activities, timeframe,
* and case duration.
*/
import { computed, ref } from "vue";
import { usePageAdminStore } from "@/stores/pageAdmin";
import { useMapPathStore } from "@/stores/mapPathStore";
import { getTimeLabel } from "@/module/timeLabel.js";
import getMoment from "moment";
import i18next from "@/i18n/i18n";
import { INSIGHTS_FIELDS_AND_LABELS } from "@/constants/constants";
// Remove the first and second elements
const fieldNamesAndLabelNames = [...INSIGHTS_FIELDS_AND_LABELS].slice(2);
const props = defineProps({
sidebarState: {
type: Boolean,
require: false,
},
stats: {
type: Object,
required: false,
},
insights: {
type: Object,
required: false,
},
});
const pageAdmin = usePageAdminStore();
const mapPathStore = useMapPathStore();
const activeTrace = ref(0);
const currentMapFile = computed(() => pageAdmin.currentMapFile);
const clickedPathListIndex = ref(0);
const isBPMNOn = computed(() => mapPathStore.isBPMNOn);
const tab = ref("summary");
const valueCases = ref(0);
const valueTraces = ref(0);
const valueTaskInstances = ref(0);
const valueTasks = ref(0);
/**
* Handles click on an active trace to highlight it.
* @param {number} clickedActiveTraceIndex - The clicked trace index.
*/
function onActiveTraceClick(clickedActiveTraceIndex) {
mapPathStore.clearAllHighlight();
activeTrace.value = clickedActiveTraceIndex;
mapPathStore.highlightClickedPath(
clickedActiveTraceIndex,
clickedPathListIndex.value,
);
}
/**
* Handles click on a path option to highlight it.
* @param {number} clickedPath - The clicked path index.
*/
function onPathOptionClick(clickedPath) {
clickedPathListIndex.value = clickedPath;
mapPathStore.highlightClickedPath(activeTrace.value, clickedPath);
}
/** Resets the trace highlight to default. */
function onResetTraceBtnClick() {
if (isBPMNOn.value) {
return;
}
clickedPathListIndex.value = undefined;
}
/**
* @param {string} newTab Summary or Insight
*/
function switchTab(newTab) {
tab.value = newTab;
}
/**
* @param {number} time use timeLabel.js
*/
function timeLabel(time) {
// sonar-qube prevent super-linear runtime due to backtracking; change * to ?
//
const label = getTimeLabel(time).replaceAll(/\s+/g, " "); // Collapse all consecutive whitespace into a single space
const result = label.match(/^([\d.]+)\s?([a-zA-Z]+)$/); // add ^ and $ to meet sonar-qube need
return result;
}
/**
* @param {number} time use moment
*/
function moment(time) {
return getMoment(time).format("YYYY-MM-DD HH:mm");
}
/**
* Number to percentage
* @param {number} val - The raw ratio value.
* @returns {string} The formatted percentage string.
*/
function getPercentLabel(val) {
if (Number((val * 100).toFixed(1)) >= 100) return `100%`;
else return `${(val * 100).toFixed(1)}%`;
}
/**
* Behavior when show
*/
function show() {
valueCases.value = props.stats.cases.ratio * 100;
valueTraces.value = props.stats.traces.ratio * 100;
valueTaskInstances.value = props.stats.task_instances.ratio * 100;
valueTasks.value = props.stats.tasks.ratio * 100;
}
/**
* Behavior when hidden
*/
function hide() {
valueCases.value = 0;
valueTraces.value = 0;
valueTaskInstances.value = 0;
valueTasks.value = 0;
}
</script>
<style scoped>
@reference "../../../assets/tailwind.css";
:deep(.p-progressbar .p-progressbar-value) {
@apply bg-primary;
}
:deep(.p-tablist) {
@apply hidden;
}
:deep(.p-tabpanels) {
@apply p-2 rounded;
}
:deep(.p-tabpanel) {
@apply animate-fadein;
}
.caseDurationTable td {
@apply scroll-pb-12;
}
.caseDurationTable td:nth-child(2) {
@apply text-right;
}
.caseDurationTable td:last-child {
@apply pl-2;
}
</style>

View File

@@ -1,394 +0,0 @@
<template>
<Drawer
:visible="sidebarTraces"
:closeIcon="'pi pi-chevron-left'"
:modal="false"
position="left"
:dismissable="false"
class="!w-11/12"
@show="show()"
>
<template #header>
<p class="h1">Traces</p>
</template>
<div class="pt-4 h-full flex items-center justify-start">
<!-- Trace List -->
<section class="w-80 h-full pr-4 border-r border-neutral-300">
<p class="h2 px-2 mb-2">Trace List ({{ traceTotal }})</p>
<p class="text-primary h2 px-2 mb-2">Click trace number to see more.</p>
<div
class="overflow-y-scroll overflow-x-hidden scrollbar mx-[-8px] max-h-[calc(100%_-_96px)]"
>
<table class="border-separate border-spacing-x-2 text-sm">
<caption class="hidden">
Trace List
</caption>
<thead class="sticky top-0 z-10 bg-neutral-10">
<tr>
<th class="h2 px-2 border-b border-neutral-500">Trace</th>
<th
class="h2 px-2 border-b border-neutral-500 text-start"
colspan="3"
>
Occurrences
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(trace, key) in traceList"
:key="key"
class="cursor-pointer hover:text-primary"
@click="switchCaseData(trace.id, trace.base_count)"
>
<td class="p-2">#{{ trace.id }}</td>
<td class="p-2 w-24">
<div
class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden"
>
<div class="h-full bg-primary" :style="trace.value"></div>
</div>
</td>
<td class="py-2 text-right">{{ trace.count }}</td>
<td class="p-2 text-right">{{ trace.ratio }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Trace item Table -->
<section class="pl-4 h-full w-[calc(100%_-_320px)]">
<p class="h2 mb-2">Trace #{{ showTraceId }}</p>
<div class="h-36 w-full px-2 mb-2 border border-neutral-300 rounded">
<div class="h-full w-full">
<div
id="cyTrace"
ref="cyTraceRef"
class="h-full min-w-full relative"
></div>
</div>
</div>
<div
class="overflow-y-auto overflow-x-auto scrollbar w-full h-[calc(100%_-_200px)] infiniteTable"
@scroll="handleScroll"
>
<DataTable
:value="caseData"
showGridlines
tableClass="text-sm"
breakpoint="0"
>
<div v-for="col in columnData" :key="col.field">
<Column :field="col.field" :header="col.header">
<template #body="{ data }">
<div
:class="
data[col.field]?.length > 18
? 'whitespace-normal'
: 'whitespace-nowrap'
"
>
{{ data[col.field] }}
</div>
</template>
</Column>
</div>
</DataTable>
</div>
</section>
</div>
</Drawer>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/Map/SidebarTraces
* Traces sidebar showing path insights with
* clickable trace lists for highlighting on the map.
*/
import { ref, computed, watch, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useLoadingStore } from "@/stores/loading";
import { useAllMapDataStore } from "@/stores/allMapData";
import cytoscapeMapTrace from "@/module/cytoscapeMapTrace.js";
import { cloneDeep } from "lodash-es";
const props = defineProps(["sidebarTraces", "cases"]);
const emit = defineEmits(["switch-Trace-Id"]);
const loadingStore = useLoadingStore();
const allMapDataStore = useAllMapDataStore();
const { isLoading } = storeToRefs(loadingStore);
const {
infinit404,
infiniteStart,
traceId,
traces,
traceTaskSeq,
infiniteFirstCases,
} = storeToRefs(allMapDataStore);
const processMap = ref({
nodes: [],
edges: [],
});
const showTraceId = ref(null);
const infinitMaxItems = ref(false);
const infiniteData = ref([]);
const infiniteFinish = ref(true); // Whether infinite scroll loading is complete
const cyTraceRef = ref(null);
const cyTraceInstance = ref(null);
const traceTotal = computed(() => {
return traces.value.length;
});
const traceList = computed(() => {
const sum = traces.value
.map((trace) => trace.count)
.reduce((acc, cur) => acc + cur, 0);
if (sum === 0) return [];
const result = traces.value.map((trace) => {
return {
id: trace.id,
value: progressWidth(Number(((trace.count / sum) * 100).toFixed(1))),
count: trace.count.toLocaleString("en-US"),
base_count: trace.count,
ratio: getPercentLabel(trace.count / sum),
};
});
return result;
});
const caseData = computed(() => {
const data = cloneDeep(infiniteData.value);
data.forEach((item) => {
item.attributes.forEach((attribute, index) => {
item[`att_${index}`] = attribute.value; // Create a new key-value pair
});
delete item.attributes; // Remove the original attributes property
});
return data;
});
const columnData = computed(() => {
const data = cloneDeep(props.cases);
let result = [
{ field: "id", header: "Case Id" },
{ field: "started_at", header: "Start time" },
{ field: "completed_at", header: "End time" },
];
if (data.length !== 0) {
result = [
{ field: "id", header: "Case Id" },
{ field: "started_at", header: "Start time" },
{ field: "completed_at", header: "End time" },
...(data[0]?.attributes ?? []).map((att, index) => ({
field: `att_${index}`,
header: att.key,
})),
];
}
return result;
});
watch(infinit404, (newValue) => {
if (newValue === 404) infinitMaxItems.value = true;
});
watch(
traceId,
(newValue) => {
showTraceId.value = newValue;
},
{ immediate: true },
);
watch(showTraceId, (newValue, oldValue) => {
const isScrollTop = document.querySelector(".infiniteTable");
if (isScrollTop && isScrollTop.scrollTop !== undefined)
if (newValue !== oldValue) isScrollTop.scrollTop = 0;
});
watch(infiniteFirstCases, (newValue) => {
if (infiniteFirstCases.value)
infiniteData.value = cloneDeep(newValue);
});
/**
* Number to percentage
* @param {number} val - The raw ratio value.
* @returns {string} The formatted percentage string.
*/
function getPercentLabel(val) {
if (Number((val * 100).toFixed(1)) >= 100) return `100%`;
else return `${(val * 100).toFixed(1)}%`;
}
/**
* set progress bar width
* @param {number} value - The percentage value.
* @returns {string} The CSS width style string.
*/
function progressWidth(value) {
return `width:${value}%;`;
}
/**
* switch case data
* @param {number} id case id
* @param {number} count - The total number of cases.
*/
async function switchCaseData(id, count) {
// Do nothing if clicking the same id
if (id === showTraceId.value) return;
isLoading.value = true; // Always show loading screen
infinit404.value = null;
infinitMaxItems.value = false;
showTraceId.value = id;
infiniteStart.value = 0;
emit("switch-Trace-Id", { id: showTraceId.value, count: count }); // Pass to Map index, which will close loading
}
/**
* Assembles the trace element nodes data for Cytoscape rendering.
*/
function setNodesData() {
// Clear nodes to prevent accumulation on each render
processMap.value.nodes = [];
// Populate nodes with data returned from the API call
traceTaskSeq.value.forEach((node, index) => {
processMap.value.nodes.push({
data: {
id: index,
label: node,
backgroundColor: "#CCE5FF",
bordercolor: "#003366",
shape: "round-rectangle",
height: 80,
width: 100,
},
});
});
}
/**
* Assembles the trace edge line data for Cytoscape rendering.
*/
function setEdgesData() {
processMap.value.edges = [];
traceTaskSeq.value.forEach((edge, index) => {
processMap.value.edges.push({
data: {
source: `${index}`,
target: `${index + 1}`,
lineWidth: 1,
style: "solid",
},
});
});
// The number of edges is one less than the number of nodes
processMap.value.edges.pop();
}
/**
* create trace cytoscape's map
*/
function createCy() {
const graphId = cyTraceRef.value;
setNodesData();
setEdgesData();
cyTraceInstance.value?.destroy();
cyTraceInstance.value = cytoscapeMapTrace(processMap.value.nodes, processMap.value.edges, graphId);
}
/**
* create map
*/
async function show() {
isLoading.value = true; // Will be closed after createCy finishes
// Reset to the first trace id when sidebar closes, due to trace API dependency
showTraceId.value = traces.value[0]?.id;
infiniteStart.value = 0;
setNodesData();
setEdgesData();
createCy();
isLoading.value = false;
}
/**
* Infinite scroll: listens for scroll reaching the bottom.
* @param {Event} event - The scroll event.
*/
function handleScroll(event) {
if (
infinitMaxItems.value ||
props.cases.length < 20 ||
infiniteFinish.value === false
)
return;
const container = event.target;
const overScrollHeight =
container.scrollTop + container.clientHeight >= container.scrollHeight;
if (overScrollHeight) fetchData();
}
/**
* Infinite scroll: loads more data when the bottom is reached.
*/
async function fetchData() {
try {
isLoading.value = true;
infiniteFinish.value = false;
infiniteStart.value += 20;
await allMapDataStore.getTraceDetail();
infiniteData.value = [...infiniteData.value, ...props.cases];
infiniteFinish.value = true;
isLoading.value = false;
} catch (error) {
console.error("Failed to load data:", error);
infiniteFinish.value = true;
isLoading.value = false;
}
}
onBeforeUnmount(() => {
cyTraceInstance.value?.destroy();
});
</script>
<style scoped>
@reference "../../../assets/tailwind.css";
/* Progress bar color */
:deep(.p-progressbar .p-progressbar-value) {
@apply bg-primary;
}
/* Table set */
:deep(.p-datatable-thead) {
@apply sticky top-0 left-0 z-10 bg-neutral-10;
}
:deep(.p-datatable .p-datatable-thead > tr > th) {
@apply !border-y-0 border-neutral-500 bg-neutral-100 after:absolute after:left-0 after:w-full after:h-full after:block after:top-0 after:border-b after:border-t after:border-neutral-500;
white-space: nowrap;
}
:deep(.p-datatable .p-datatable-tbody > tr > td) {
@apply border-neutral-500 !border-t-0 text-center;
}
:deep(.p-datatable.p-datatable-gridlines .p-datatable-tbody > tr > td) {
min-width: 72px;
max-width: 184px;
overflow-wrap: break-word;
word-wrap: break-word;
}
/* Center datatable header */
:deep(.p-column-header-content) {
@apply justify-center;
}
</style>

View File

@@ -1,273 +0,0 @@
<template>
<Drawer
:visible="sidebarView"
:closeIcon="'pi pi-chevron-left'"
:modal="false"
position="left"
:dismissable="false"
>
<template #header>
<p class="h1">Visualization Setting</p>
</template>
<div>
<!-- View -->
<div class="my-4 border-b border-neutral-200">
<p class="h2">View</p>
<ul class="space-y-3 mb-4">
<!-- Select bpmn / processmap button -->
<li class="btn-toggle-content">
<span
class="btn-toggle-item"
:class="mapType === 'processMap' ? 'btn-toggle-show ' : ''"
@click="onProcessMapClick()"
>
Process Map
</span>
<span
class="btn-toggle-item"
:class="mapType === 'bpmn' ? 'btn-toggle-show' : ''"
@click="onBPMNClick()"
>
BPMN Model
</span>
</li>
<!-- Select drawing style: bezier / unbundled-bezier button -->
<li class="btn-toggle-content">
<span
class="btn-toggle-item"
:class="
curveStyle === 'unbundled-bezier' ? 'btn-toggle-show ' : ''
"
@click="switchCurveStyles('unbundled-bezier')"
>
Curved
</span>
<span
class="btn-toggle-item"
:class="curveStyle === 'taxi' ? 'btn-toggle-show' : ''"
@click="switchCurveStyles('taxi')"
>
Elbow
</span>
</li>
<!-- Vertical TB | Horizontal LR -->
<li class="btn-toggle-content">
<span
class="btn-toggle-item"
:class="rank === 'LR' ? 'btn-toggle-show ' : ''"
@click="switchRank('LR')"
>
Horizontal
</span>
<span
class="btn-toggle-item"
:class="rank === 'TB' ? 'btn-toggle-show' : ''"
@click="switchRank('TB')"
>
Vertical
</span>
</li>
</ul>
</div>
<!-- Data Layer -->
<div>
<p class="h2">Data Layer</p>
<ul class="space-y-2">
<li
class="flex justify-between mb-3"
@change="switchDataLayerType($event, 'freq')"
>
<div class="flex items-center w-1/2">
<RadioButton
v-model="dataLayerType"
inputId="freq"
name="dataLayer"
value="freq"
class="mr-2"
@click.prevent="switchDataLayerType($event, 'freq')"
/>
<label for="freq">Frequency</label>
</div>
<div class="w-1/2">
<select
class="border border-neutral-500 rounded p-1 w-full focus-visible:outline-primary"
:disabled="dataLayerType === 'duration'"
>
<option
v-for="(freq, index) in selectFrequency"
:key="index"
:value="freq.value"
:disabled="freq.disabled"
:selected="freq.value === selectedFreq"
>
{{ freq.label }}
</option>
</select>
</div>
</li>
<li
class="flex justify-between mb-3"
@change="switchDataLayerType($event, 'duration')"
>
<div class="flex items-center w-1/2">
<RadioButton
v-model="dataLayerType"
inputId="duration"
name="dataLayer"
value="duration"
class="mr-2"
@click.prevent="switchDataLayerType($event, 'duration')"
/>
<label for="duration">Duration</label>
</div>
<div class="w-1/2">
<select
class="border border-neutral-500 rounded p-1 w-full focus-visible:outline-primary"
:disabled="dataLayerType === 'freq'"
>
<option
v-for="(duration, index) in selectDuration"
:key="index"
:value="duration.value"
:disabled="duration.disabled"
:selected="duration.value === selectedDuration"
>
{{ duration.label }}
</option>
</select>
</div>
</li>
</ul>
</div>
</div>
</Drawer>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/Map/SidebarView Visualization
* settings sidebar for map view type (process/BPMN), curve
* style, direction, and data layer selection.
*/
import { ref, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useMapPathStore } from "@/stores/mapPathStore";
defineProps({
sidebarView: {
type: Boolean,
require: true,
},
});
const emit = defineEmits([
"switch-map-type",
"switch-curve-styles",
"switch-rank",
"switch-data-layer-type",
]);
const mapPathStore = useMapPathStore();
const { isBPMNOn } = storeToRefs(mapPathStore);
const selectFrequency = ref([
{ value: "total", label: "Total", disabled: false },
{ value: "rel_freq", label: "Relative", disabled: false },
{ value: "average", label: "Average", disabled: false },
{ value: "median", label: "Median", disabled: false },
{ value: "max", label: "Max", disabled: false },
{ value: "min", label: "Min", disabled: false },
{ value: "cases", label: "Number of cases", disabled: false },
]);
const selectDuration = ref([
{ value: "total", label: "Total", disabled: false },
{ value: "rel_duration", label: "Relative", disabled: false },
{ value: "average", label: "Average", disabled: false },
{ value: "median", label: "Median", disabled: false },
{ value: "max", label: "Max", disabled: false },
{ value: "min", label: "Min", disabled: false },
]);
const curveStyle = ref("unbundled-bezier"); // unbundled-bezier | taxi
const mapType = ref("processMap"); // processMap | bpmn
const dataLayerType = ref(null); // freq | duration
const dataLayerOption = ref(null);
const selectedFreq = ref("");
const selectedDuration = ref("");
const rank = ref("LR"); // Vertical TB | Horizontal LR
/**
* Switches the map type and emits the change event.
* @param {string} type - 'processMap' or 'bpmn'.
*/
function switchMapType(type) {
mapType.value = type;
emit("switch-map-type", mapType.value);
}
/**
* Switches the curve style and emits the change event.
* @param {string} style - 'unbundled-bezier' (curved) or 'taxi' (elbow).
*/
function switchCurveStyles(style) {
curveStyle.value = style;
emit("switch-curve-styles", curveStyle.value);
}
/**
* Switches the graph layout direction and emits the change event.
* @param {string} rankValue - 'TB' (vertical) or 'LR' (horizontal).
*/
function switchRank(rankValue) {
rank.value = rankValue;
emit("switch-rank", rank.value);
}
/**
* Switches the data layer type (frequency or duration) and option.
* @param {Event} e - The change event from the radio or select.
* @param {string} type - 'freq' or 'duration'.
*/
function switchDataLayerType(e, type) {
let value = "";
if (e.target.value !== "freq" && e.target.value !== "duration")
value = e.target.value;
switch (type) {
case "freq":
value = value || selectedFreq.value || "total";
dataLayerType.value = type;
dataLayerOption.value = value;
selectedFreq.value = value;
break;
case "duration":
value = value || selectedDuration.value || "total";
dataLayerType.value = type;
dataLayerOption.value = value;
selectedDuration.value = value;
break;
}
emit("switch-data-layer-type", dataLayerType.value, dataLayerOption.value);
}
/** Switches to Process Map view. */
function onProcessMapClick() {
mapPathStore.setIsBPMNOn(false);
switchMapType("processMap");
}
/** Switches to BPMN Model view. */
function onBPMNClick() {
mapPathStore.setIsBPMNOn(true);
switchMapType("bpmn");
}
onMounted(() => {
dataLayerType.value = "freq";
dataLayerOption.value = "total";
});
</script>

View File

@@ -1,245 +0,0 @@
<template>
<section
class="w-full top-0 absolute shadow-[0px_6px_6px_inset_rgba(0,0,0,0.1)] z-20"
>
<!-- status content -->
<ul
class="bg-neutral-100 flex justify-start shadow-[0px_1px_4px_rgba(0,0,0,0.2)] gap-3 p-3 text-sm overflow-x-auto scrollbar duration-700"
v-show="isPanel"
v-if="statData"
>
<li class="bg-neutral-10 rounded p-3 w-full">
<div class="flex justify-between items-center mb-5">
<p class="font-bold text-sm leading-8">Cases</p>
</div>
<div class="flex justify-between items-center">
<div class="mr-2 w-full">
<span class="block text-sm mb-2"
>{{ statData.cases.count }} / {{ statData.cases.total }}</span
>
<ProgressBar
:value="statData.cases.ratio"
:showValue="false"
class="!h-1.5 min-w-[136px] rounded !bg-neutral-200"
></ProgressBar>
</div>
<span class="block text-2xl font-medium"
>{{ statData.cases.ratio }}%</span
>
</div>
</li>
<li class="bg-neutral-10 rounded p-3 w-full">
<div class="flex justify-between items-center mb-5">
<p class="font-bold text-sm leading-8">Traces</p>
</div>
<div class="flex justify-between items-center">
<div class="mr-2 w-full">
<span class="block text-sm mb-2"
>{{ statData.traces.count }} / {{ statData.traces.total }}</span
>
<ProgressBar
:value="statData.traces.ratio"
:showValue="false"
class="!h-1.5 min-w-[136px] rounded !bg-neutral-200"
></ProgressBar>
</div>
<span class="block text-2xl font-medium"
>{{ statData.traces.ratio }}%</span
>
</div>
</li>
<li class="bg-neutral-10 rounded p-3 w-full">
<div class="flex justify-between items-center mb-5">
<p class="font-bold text-sm leading-8">Activity Instances</p>
</div>
<div class="flex justify-between items-center">
<div class="mr-2 w-full">
<span class="block text-sm mb-2"
>{{ statData.task_instances.count }} /
{{ statData.task_instances.total }}</span
>
<ProgressBar
:value="statData.task_instances.ratio"
:showValue="false"
class="!h-1.5 min-w-[136px] rounded !bg-neutral-200"
></ProgressBar>
</div>
<span class="block text-2xl font-medium"
>{{ statData.task_instances.ratio }}%</span
>
</div>
</li>
<li class="bg-neutral-10 rounded p-3 w-full">
<div class="flex justify-between items-center mb-5">
<p class="font-bold text-sm leading-8">Activities</p>
</div>
<div class="flex justify-between items-center">
<div class="mr-2 w-full">
<span class="block text-sm mb-2"
>{{ statData.tasks.count }} / {{ statData.tasks.total }}</span
>
<ProgressBar
:value="statData.tasks.ratio"
:showValue="false"
class="!h-1.5 min-w-[136px] rounded !bg-neutral-200"
></ProgressBar>
</div>
<span class="block text-2xl font-medium"
>{{ statData.tasks.ratio }}%</span
>
</div>
</li>
<li class="bg-neutral-10 rounded p-3 w-full">
<p class="font-bold text-sm leading-8 mb-2.5">Log Timeframe</p>
<div class="px-2 space-y-2 min-w-[140px] h-[40px]">
<span class="inline-block">{{ statData.started_at }}&nbsp;</span>
<span class="inline-block">~&nbsp;{{ statData.completed_at }}</span>
</div>
</li>
<li class="bg-neutral-10 rounded p-3 w-full">
<p class="font-bold text-sm leading-8">Case Duration</p>
<div class="flex justify-between items-center space-x-2 min-w-[272px]">
<div class="space-y-2">
<p>
<Tag
value="MAX"
class="!text-neutral-900 !bg-neutral-300 mr-2 !w-10 !text-sm !px-2 !py-0"
></Tag
>{{ statData.case_duration.max }}
</p>
<p>
<Tag
value="MIN"
class="!text-neutral-900 !bg-neutral-300 mr-2 !w-10 !text-sm !px-2 !py-0"
></Tag
>{{ statData.case_duration.min }}
</p>
</div>
<div class="space-y-2">
<p>
<Tag
value="MED"
class="!text-neutral-900 !bg-neutral-300 mr-2 !w-10 !text-sm !px-2 !py-0"
></Tag
>{{ statData.case_duration.median }}
</p>
<p>
<Tag
value="AVG"
class="!text-neutral-900 !bg-neutral-300 mr-2 !w-10 !text-sm !px-2 !py-0"
></Tag
>{{ statData.case_duration.average }}
</p>
</div>
</div>
</li>
</ul>
<!-- control button -->
<div
class="bg-neutral-300 rounded-b-full w-20 text-center mx-auto cursor-pointer hover:bg-neutral-500 hover:text-neutral-10 active:ring focus:outline-none focus:border-neutral-500 focus:ring"
@click="isPanel = !isPanel"
>
<span class="material-symbols-outlined block px-8 !text-xs">{{
isPanel ? "keyboard_double_arrow_up" : "keyboard_double_arrow_down"
}}</span>
</div>
</section>
</template>
<script setup>
// The Lucia project.
// Copyright 2024-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
/**
* @module components/Discover/StatusBar Collapsible status bar
* showing dataset statistics (cases, traces, activities,
* timeframe, case duration) for the Discover page.
*/
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { storeToRefs } from "pinia";
import { useAllMapDataStore } from "@/stores/allMapData";
import { getTimeLabel } from "@/module/timeLabel.js";
import getMoment from "moment";
const route = useRoute();
const allMapDataStore = useAllMapDataStore();
const { logId, stats, createFilterId } = storeToRefs(allMapDataStore);
const isPanel = ref(false);
const statData = ref(null);
/**
* Converts a ratio (01) to a percentage number (0100), capped at 100.
* @param {number} val - The ratio value to convert.
* @returns {number} The percentage value.
*/
function getPercentLabel(val) {
if (Number((val * 100).toFixed(1)) >= 100) return 100;
else return Number.parseFloat((val * 100).toFixed(1));
}
/** Transforms raw stats into display-ready format with localized numbers and time labels. */
function getStatData() {
if (!stats.value) return;
statData.value = {
cases: {
count: stats.value.cases.count.toLocaleString("en-US"),
total: stats.value.cases.total.toLocaleString("en-US"),
ratio: getPercentLabel(stats.value.cases.ratio),
},
traces: {
count: stats.value.traces.count.toLocaleString("en-US"),
total: stats.value.traces.total.toLocaleString("en-US"),
ratio: getPercentLabel(stats.value.traces.ratio),
},
task_instances: {
count: stats.value.task_instances.count.toLocaleString("en-US"),
total: stats.value.task_instances.total.toLocaleString("en-US"),
ratio: getPercentLabel(stats.value.task_instances.ratio),
},
tasks: {
count: stats.value.tasks.count.toLocaleString("en-US"),
total: stats.value.tasks.total.toLocaleString("en-US"),
ratio: getPercentLabel(stats.value.tasks.ratio),
},
started_at: getMoment(stats.value.started_at).format("YYYY-MM-DD HH:mm"),
completed_at: getMoment(stats.value.completed_at).format(
"YYYY-MM-DD HH:mm",
),
case_duration: {
min: getTimeLabel(stats.value.case_duration.min),
max: getTimeLabel(stats.value.case_duration.max),
average: getTimeLabel(stats.value.case_duration.average),
median: getTimeLabel(stats.value.case_duration.median),
},
};
}
onMounted(async () => {
const params = route.params;
const file = route.meta.file;
const isCheckPage = route.name.includes("Check");
switch (params.type) {
case "log":
logId.value = isCheckPage ? file.parent?.id : params.fileId;
break;
case "filter":
createFilterId.value = isCheckPage ? file.parent?.id : params.fileId;
break;
}
await allMapDataStore.getAllMapData();
await getStatData();
isPanel.value = false; // Collapsed by default
});
</script>
<style scoped>
@reference "../../assets/tailwind.css";
:deep(.p-progressbar .p-progressbar-value) {
@apply bg-neutral-900;
}
</style>

View File

@@ -0,0 +1,513 @@
<template>
<Sidebar :visible="sidebarFilter" :closeIcon="'pi pi-chevron-left'" :modal="false" position="left" :dismissable="true" :baseZIndex="15" class="!w-11/12 !bg-neutral-100">
<template #header>
<ul class="flex space-x-4">
<li class="h1 border-r-2 border-neutral-300 pr-4 cursor-pointer hover:text-neutral-900 hover:duration-700" @click="switchTab('filter')" :class="tab === 'filter'? 'text-neutral-900': 'text-neutral-500'">Filter</li>
<li class="h1 border-r-2 border-neutral-300 pr-4 cursor-pointer hover:text-neutral-900 hover:duration-700" @click="switchTab('funnel')" :class="tab === 'funnel'? 'text-neutral-900': 'text-neutral-500'">Funnel</li>
</ul>
</template>
<!-- header: filter -->
<div v-if="tab === 'filter'" class="pt-4 bg-neutral-100 flex w-full h-full">
<!-- title: filter silect -->
<div class="space-y-2 mr-4 w-56 text-sm">
<div>
<p class="h2">Filter Type</p>
<div v-for="(item, index) in selectFilter['Filter Type']" :key="index" class="flex align-items-center">
<RadioButton v-model="selectValue[0]" :inputId="item + index" name="Filter Type" :value="item" />
<label :for="item + index" class="ml-2">{{ item }}</label>
</div>
</div>
<div v-show="selectValue[0] === 'Sequence'">
<p class="h2">Activity Sequence</p>
<div v-for="(item, index) in selectFilter['Activity Sequence']" :key="index" class="flex align-items-center">
<RadioButton v-model="selectValue[1]" :inputId="item + index" name="Activity Sequence" :value="item" />
<label :for="item + index" class="ml-2">{{ item }}</label>
</div>
</div>
<div v-show="selectValue[1] === 'Start activity & end activity'">
<p class="h2">Start & End</p>
<div v-for="(item, index) in selectFilter['Start & End']" :key="index" class="flex align-items-center">
<RadioButton v-model="selectValue[2]" :inputId="item + index" name="Start & End" :value="item" />
<label :for="item + index" class="ml-2">{{ item }}</label>
</div>
</div>
<div v-show="selectValue[0] === 'Sequence' && selectValue[1] === 'Sequence'">
<p class="h2">Mode</p>
<div v-for="(item, index) in selectFilter['Mode']" :key="index" class="flex align-items-center">
<RadioButton v-model="selectValue[3]" :inputId="item + index" name="Mode" :value="item" />
<label :for="item + index" class="ml-2">{{ item }}</label>
</div>
</div>
<div v-show="selectValue[0] === 'Attributes'">
<p class="h2">Mode</p>
<div v-for="(item, index) in selectFilter['ModeAtt']" :key="index" class="flex align-items-center">
<RadioButton v-model="selectValue[4]" :inputId="item + index" name="ModeAtt" :value="item" />
<label :for="item + index" class="ml-2">{{ item }}</label>
</div>
</div>
<div>
<p class="h2">Refine</p>
<div v-for="(item, index) in selectFilter['Refine']" :key="index" class="flex align-items-center">
<RadioButton v-model="selectValue[5]" :inputId="item + index" name="Refinee" :value="item" />
<label :for="item + index" class="ml-2">{{ item }}</label>
</div>
</div>
<div v-show="selectValue[0] === 'Timeframes'">
<p class="h2">Containment</p>
<div v-for="(item, index) in selectFilter['Containment']" :key="index" class="flex align-items-center">
<RadioButton v-model="selectValue[6]" :inputId="item + index" name="Containment" :value="item" />
<label :for="item + index" class="ml-2">{{ item }}</label>
</div>
</div>
</div>
<!-- title: Activity Select -->
<div class="space-y-2 w-[calc(100%_-_240px)] h-[calc(100%_-_106px)]">
<p class="h2 ml-1">Activity Select</p>
<!-- Filter task Data-->
<ActOccCase v-if="selectValue[0] === 'Sequence' && selectValue[1] === 'Have activity(s)'" :tableTitle="'Activity List'" :tableData="filterTaskData" :tableSelect="selectFilterTask" :progressWidth ="progressWidth" @on-row-select="onRowAct"></ActOccCase>
<!-- Filter Start Data -->
<ActOcc v-if="selectValue[0] === 'Sequence' && selectValue[1] === 'Start activity & end activity' && selectValue[2] === 'Start'" :tableTitle="'Start activity'" :tableData="filterStartData" :tableSelect="selectFilterStart" :progressWidth ="progressWidth" @on-row-select="onRowStart"></ActOcc>
<!-- Filter End Data -->
<ActOcc v-if="selectValue[0] === 'Sequence' && selectValue[1] === 'Start activity & end activity' && selectValue[2] === 'End'" :tableTitle="'End activity'" :tableData="filterEndData" :tableSelect="selectFilterEnd" :progressWidth ="progressWidth" @on-row-select="onRowEnd"></ActOcc>
<!-- Filter Start And End Data -->
<div v-if="selectValue[0] === 'Sequence' && selectValue[1] === 'Start activity & end activity' && selectValue[2] === 'Start & End'" class="flex justify-between items-center w-full h-full space-x-4 ">
<ActOcc :tableTitle="'Start activity'" :tableData="filterStartToEndData" :tableSelect="selectFilterStartToEnd" :progressWidth ="progressWidth" class="w-1/2" @on-row-select="startRow"></ActOcc>
<ActOcc :tableTitle="'End activity'" :tableData="filterEndToStartData" :tableSelect="selectFilterEndToStart" :progressWidth ="progressWidth" class="w-1/2" @on-row-select="endRow"></ActOcc>
</div>
<!-- Filter Sequence -->
<div v-if="selectValue[0] === 'Sequence' && selectValue[1] === 'Sequence'" class="flex justify-between items-center w-full h-full space-x-4">
<ActAndSeq :filterTaskData="filterTaskData" :progressWidth ="progressWidth" :listSeq="listSeq" @update:listSeq="onUpdateListSeq"></ActAndSeq>
</div>
<!-- Button -->
<div class="float-right space-x-4 px-4 py-2">
<button class="btn btn-sm btn-neutral" @click="reset">Clear</button>
<button class="btn btn-sm btn-neutral" @click="submit">Apply</button>
</div>
</div>
</div>
<!-- header: funnel -->
<div v-if="tab === 'funnel'" class="bg-neutral-10 w-full h-full">
<div class="h-[calc(100%_-_58px)] border-b border-neutral-300 mb-2">
<div v-if="temporaryData.length === 0" class="h-full flex justify-center items-center">
<span class="text-neutral-500">No Filter.</span>
</div>
<div v-else>
temporaryData:{{ temporaryData }}
<Timeline :value="events">
<template #content="slotProps">
{{ slotProps.item.status }}
</template>
</Timeline>
<!-- <Timeline :value="events" align="alternate" class="customized-timeline">
<template #marker="slotProps">
<span class="!flex !w-2rem !h-2rem !align-items-center !justify-content-center !text-white !border-circle !z-1 !shadow-1" :style="{ backgroundColor: slotProps.item.color }">
<i :class="slotProps.item.icon"></i>
</span>
</template>
<template #content="slotProps">
{{ slotProps.item.status }}
<Card>
<template #title>
{{ slotProps.item.status }}
</template>
<template #subtitle>
{{ slotProps.item.date }}
</template>
<template #content>
<img v-if="slotProps.item.image" :src="`https://primefaces.org/cdn/primevue/images/product/${slotProps.item.image}`" :alt="slotProps.item.name" width="200" class="shadow-1" />
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Inventore sed consequuntur error repudiandae numquam deserunt quisquam repellat libero asperiores earum nam nobis, culpa ratione quam perferendis esse, cupiditate
neque quas!
</p>
<Button label="Read more" text></Button>
</template>
</Card>
</template>
</Timeline> -->
</div>
</div>
<!-- Button -->
<div class="">
<div class="float-right space-x-4 px-4 py-2">
<button class="btn btn-sm btn-neutral" @click="deleteAll">Delete All</button>
<button class="btn btn-sm btn-neutral" @click="submitAll">Apply All</button>
</div>
</div>
</div>
</Sidebar>
</template>
<script>
import { storeToRefs } from 'pinia';
import LoadingStore from '@/stores/loading.js';
import AllMapDataStore from '@/stores/allMapData.js';
import ActOccCase from '@/components/Discover/table/actOccCase.vue';
import ActOcc from '@/components/Discover/table/actOcc.vue';
import ActAndSeq from '@/components/Discover/table/actAndSeq.vue';
export default {
props: {
sidebarFilter: {
type: Boolean,
require: true,
},
filterTasks: {
type: Array,
require: true,
},
filterStartToEnd: {
type: Array,
require: true,
},
filterEndToStart: {
type: Array,
require: true,
},
filterTimeframe: {
type: Object,
require: true,
},
filterTrace: {
type: Array,
require: true,
},
},
setup() {
const loadingStore = LoadingStore();
const allMapDataStore = AllMapDataStore();
const { isLoading } = storeToRefs(loadingStore);
const { tempFilterId } = storeToRefs(allMapDataStore);
return { isLoading, tempFilterId, allMapDataStore }
},
data() {
return {
events: [
{ status: 'Ordered', date: '15/10/2020 10:30', icon: 'pi pi-shopping-cart', color: '#9C27B0'},
{ status: 'Processing', date: '15/10/2020 14:00', icon: 'pi pi-cog', color: '#673AB7' },
{ status: 'Shipped', date: '15/10/2020 16:15', icon: 'pi pi-shopping-cart', color: '#FF9800' },
{ status: 'Delivered', date: '16/10/2020 10:00', icon: 'pi pi-check', color: '#607D8B' }
],
selectFilter: {
'Filter Type': ['Sequence', 'Attributes', 'Trace', 'Timeframes'],
'Activity Sequence':['Have activity(s)', 'Start activity & end activity', 'Sequence'],
'Start & End': ['Start', 'End', 'Start & End'],
'Mode': ['Directly follows', 'Eventually follows'],
'ModeAtt': ['Case', 'Activity'],
'Refine': ['Include', 'Exclude'],
'Containment': ['Contained in', 'Started in', 'End in', 'Activity in', 'Trim'],
},
tab: 'filter', // filter | funnel
// selectValue: ['Sequence', 'Have activity(s)', 'Start', 'Directly follows', 'Case', 'Include', 'Contained in'],
selectValue: {
0: 'Sequence',
1: 'Have activity(s)',
2: 'Start',
3: 'Directly follows',
4: 'Case',
5: 'Include',
6: 'Contained in',
},
selectFilterTask: null,
selectFilterStart: null,
selectFilterEnd: null,
selectFilterStartToEnd: null,
selectFilterEndToStart: null,
listSeq: [],
//若第一次選擇 start, 則 end 連動改變,若第一次選擇 end, 則 start 連動改變
isStartSelected: null,
isEndSelected: null,
isActAllTask: true,
rowData: [],
temporaryData: [],
}
},
components: {
ActOccCase,
ActOcc,
ActAndSeq,
},
computed: {
// All Task
filterTaskData: function() {
return this.isActAllTask? this.setHaveAct(this.filterTasks) : this.filterTaskData;
},
// Start and End Task
filterStartData: function() {
return this.setActData(this.filterStartToEnd);
},
filterEndData: function() {
return this.setActData(this.filterEndToStart);
},
filterStartToEndData: function() {
return this.isEndSelected ? this.setStartAndEndData(this.filterEndToStart, this.rowData, 'sources') : this.setActData(this.filterStartToEnd);
},
filterEndToStartData: function() {
return this.isStartSelected ? this.setStartAndEndData(this.filterStartToEnd, this.rowData, 'sinks') : this.setActData(this.filterEndToStart);
}
},
methods: {
/**
* @param {string} switch Summary or Insight
*/
switchTab(tab) {
this.tab = tab;
},
/**
* Number to percentage
* @param {number} val
* @returns {string} 轉換完成的百分比字串
*/
getPercentLabel(val){
return (val * 100 === 100) ? `${val * 100}%` : `${(val * 100).toFixed(1)}%`;
},
/**
* set progress bar width
* @param {number} value
* @returns {string} 樣式的寬度設定
*/
progressWidth(value){
return `width:${value}%;`
},
//設定 Have activity(s) 內容
/**
* @param {array} data filterTaskData
*/
setHaveAct(data){
return data.map(task => {
return {
label: task.label,
occ_value: Number(task.occurrence_ratio * 100),
occurrences: Number(task.occurrences).toLocaleString('en-US'),
occurrence_ratio: this.getPercentLabel(task.occurrence_ratio),
case_value: Number(task.case_ratio * 100),
cases: task.cases.toLocaleString('en-US'),
case_ratio: this.getPercentLabel(task.case_ratio),
};
}).sort((x, y) => y.occurrences - x.occurrences);
},
// 調整 filterStartData / filterEndData / filterStartToEndData / filterEndToStartData 的內容
/**
* @param {array} array filterStartToEnd / filterEndToStart
*/
setActData(array) {
let list = [];
array.forEach((task, index) => {
let data = {
label: task.label,
occ_value: Number(task.occurrence_ratio * 100),
occurrences: Number(task.occurrences).toLocaleString('en-US'),
occurrence_ratio: this.getPercentLabel(task.occurrence_ratio),
};
list.push(data);
});
return list;
},
/**
* @param {array} select select Have activity(s) rows
*/
onRowAct(select){
this.selectFilterTask = select;
},
/**
* @param {object} e select Start rows
*/
onRowStart(e){
this.selectFilterStart = e.data;
},
/**
* @param {object} e select End rows
*/
onRowEnd(e){
this.selectFilterEnd = e.data;
},
/**
* @param {array} e Update List Seq
*/
onUpdateListSeq(listSeq) {
this.listSeq = listSeq;
this.isActAllTask = false;
},
// 在 Start & End 若第一次選擇 start, 則 end 連動改變,若第一次選擇 end, 則 start 連動改變
/**
* @param {object} e object contains selected row's data
*/
startRow(e){
this.selectFilterStartToEnd = e.data;
if(this.isStartSelected === null || this.isStartSelected === true){
this.isStartSelected = true;
this.isEndSelected = false;
this.rowData = e.data;
}
},
endRow(e) {
this.selectFilterEndToStart = e.data;
if(this.isEndSelected === null || this.isEndSelected === true){
this.isEndSelected = true;
this.isStartSelected = false;
this.rowData = e.data;
}
},
// 重新設定連動的 filterStartToEndData / filterEndToStartData 內容
/**
* @param {array} eventData Start or End List
* @param {object} rowData 所選擇的 row's data
* @param {string} event sinks / sources
*/
setStartAndEndData(eventData, rowData, event){
const filterData = event === 'sinks' ? this.filterEndToStart : this.filterStartToEnd;
const relatedItems = eventData
.find(task => task.label === rowData.label)
?.[event]?.filter(item => filterData.some(ele => ele.label === item))
?.map(item => filterData.find(ele => ele.label === item));
if (!relatedItems) return [];
return relatedItems.map(item => ({
label: item.label,
occ_value: Number(item.occurrence_ratio * 100),
occurrences: Number(item.occurrences).toLocaleString('en-US'),
occurrence_ratio: this.getPercentLabel(item.occurrence_ratio),
}));
},
/**
* 清空選項
*/
reset(massage) {
this.selectFilterTask = null;
this.selectFilterStart = null;
this.selectFilterEnd = null;
this.selectFilterStartToEnd = null;
this.selectFilterEndToStart = null;
this.listSeq = [];
this.isStartSelected = null;
this.isEndSelected = null;
this.isActAllTask = true;
massage ? this.$toast.success('Reset Success.') : null;
},
// header:Filter 發送選取的資料
submit(){
let data;
let sele = this.selectValue;
let isExclude = sele[5] === 'Exclude' ? true : false
// Filter Type 選 Sequence 的行為
// 若陣列為空,則跳出警告訊息
if(sele[0] === 'Sequence'){
if(sele[1] === 'Have activity(s)'){ // Activity Sequence 選 Have activity(s) 的行為
if(this.selectFilterTask === null || this.selectFilterTask.length === 0) return this.$toast.error('Not selected');
else {
// 將多選的 task 拆成一包包 obj
data = this.selectFilterTask.map(task => {
return {
type : 'contains-task',
task : task.label,
is_exclude : isExclude,
}
})
};
}else if(sele[1] === 'Start activity & end activity') { // Activity Sequence 選 Start activity & end activity 的行為
if(sele[2] === 'Start') {
if(this.selectFilterStart === null || this.selectFilterStart.length === 0) return this.$toast.error('Not selected');
else {
data = {
type: 'starts-with',
task: this.selectFilterStart.label,
is_exclude: isExclude,
}
};
}else if(sele[2] === 'End') {
if(this.selectFilterEnd === null || this.selectFilterEnd.length === 0) return this.$toast.error('Not selected');
else {
data = {
type: 'starts-with',
task: this.selectFilterEnd.label,
is_exclude: isExclude,
}
};
}else if(sele[2] === 'Start & End') {
if(this.selectFilterStartToEnd === null || this.selectFilterStartToEnd.length === 0 || this.selectFilterEndToStart === null || this.selectFilterEndToStart.length === 0 ) return this.$toast.error('Both Start and End must be selected.');
else {
data = {
type: 'start-end',
starts_with: this.selectFilterStartToEnd.label,
ends_with: this.selectFilterEndToStart.label,
is_exclude: isExclude,
}
}
};
}else if(sele[1] === 'Sequence'){ // Activity Sequence 選 Sequence 的行為
if(this.listSeq.length < 2) return this.$toast.error('Select two or more.');
else {
data = {
type: sele[3] === 'Directly follows' ? 'directly-follows' : 'eventually-follows',
task_seq: this.listSeq.map(task => task.label),
is_exclude: isExclude,
}
};
}
}
// 將資料指向 Vue data 雙向綁定
const postData = Array.isArray(data) ? data : [data];
this.temporaryData.push(...postData);
// 結束後要清空資料
this.reset(false);
// 發送時isLoading
this.isLoading = true;
// 結束後,要跳出傳送成功的訊息
// 跳轉 this.tab = 'funnel';
setTimeout(() => {
this.isLoading = false;
this.$toast.success('Filter Success. Click on Funnel to view the configuration result.');
}, 1000);
// 快速檢查每一 filter 規則是否為空集合
let logId = this.$route.params.logId;
this.axios.post(`/api/filters/has-result?log_id=${logId}`, postData)
.then(res=>{
res.data.result ? null : this.$toast.warning('No result.');
}).catch(err=>{
})
},
// header:Funnel 刪除全部的 Funnel
deleteAll() {
this.temporaryData = [];
this.$toast.success('All deleted.');
},
// header:Funnel 發送暫存的選取資料
submitAll() {
let logId = this.$route.params.logId;
this.axios.post(`/api/temp-filters?log_id=${logId}`, this.temporaryData)
.then(res=>{
console.log(res);
this.tempFilterId = res.data.id;
}).catch(err=>{
console.log(err);
})
}
},
}
</script>
<style scoped>
#searchFiles::-webkit-search-cancel-button{
appearance: none;
}
/* TimeLine */
:deep(.p-timeline-event-marker) {
@apply !bg-primary !border-primary !h-2 !w-2
}
:deep(.p-timeline-event-connector) {
@apply !bg-primary my-2 !w-[1px]
}
</style>

View File

@@ -0,0 +1,266 @@
<template>
<Sidebar :visible="sidebarState" :closeIcon="'pi pi-angle-right'" :modal="false" position="right" :dismissable="true" class="!w-[360px]" @hide="hide" @show="show">
<template #header>
<ul class="flex space-x-4 pl-4">
<li class="h1 border-r-2 border-neutral-300 pr-4 cursor-pointer hover:text-neutral-900 hover:duration-700" @click="switchTab('summary')" :class="tab === 'summary'? 'text-neutral-900': ''">Summary</li>
<li class="h1 border-r-2 border-neutral-300 pr-4 cursor-pointer hover:text-neutral-900 hover:duration-700" @click="switchTab('insight')" :class="tab === 'insight'? 'text-neutral-900': ''">Insight</li>
</ul>
</template>
<!-- header: summary -->
<div v-if="tab === 'summary'">
<!-- Stats -->
<ul class="pb-4 border-b border-neutral-300">
<li>
<p class="h2">Cases</p>
<div class="flex justify-between items-center">
<div class="w-full mr-8">
<span class="block text-sm">{{ numberLabel(stats.cases.count) }} / {{ numberLabel(stats.cases.total) }}</span>
<ProgressBar :value="valueCases" :showValue="false" class="!h-2 !rounded-full my-2 !bg-neutral-300"></ProgressBar>
</div>
<span class="block text-primary text-2xl font-medium">{{ getPercentLabel(stats.cases.ratio) }}</span>
</div>
</li>
<li>
<p class="h2">Traces</p>
<div class="flex justify-between items-center">
<div class="w-full mr-8">
<span class="block text-sm">{{ numberLabel(stats.traces.count) }} / {{ numberLabel(stats.traces.total) }}</span>
<ProgressBar :value="valueTraces" :showValue="false" class="!h-2 !rounded-full my-2 !bg-neutral-300"></ProgressBar>
</div>
<span class="block text-primary text-2xl font-medium">{{ getPercentLabel(stats.traces.ratio) }}</span>
</div>
</li>
<li>
<p class="h2">Activity Instances</p>
<div class="flex justify-between items-center">
<div class="w-full mr-8">
<span class="block text-sm">{{ numberLabel(stats.task_instances.count) }} / {{ numberLabel(stats.task_instances.total) }}</span>
<ProgressBar :value="valueTaskInstances" :showValue="false" class="!h-2 !rounded-full my-2 !bg-neutral-300"></ProgressBar>
</div>
<span class="block text-primary text-2xl font-medium">{{ getPercentLabel(stats.task_instances.ratio) }}</span>
</div>
</li>
<li>
<p class="h2">Activities</p>
<div class="flex justify-between items-center">
<div class="w-full mr-8">
<span class="block text-sm">{{ numberLabel(stats.tasks.count) }} / {{ numberLabel(stats.tasks.total) }}</span>
<ProgressBar :value="valueTasks" :showValue="false" class="!h-2 !rounded-full my-2 !bg-neutral-300"></ProgressBar>
</div>
<span class="block text-primary text-2xl font-medium">{{ getPercentLabel(stats.tasks.ratio) }}</span>
</div>
</li>
</ul>
<!-- Log Timeframe -->
<div class="pt-1 pb-4 border-b border-neutral-300">
<p class="h2">Log Timeframe</p>
<p><span class="px-4">{{ moment(stats.started_at
) }}</span>~<span class="px-4">{{ moment(stats.completed_at
) }}</span></p>
</div>
<!-- Case Duration -->
<div class="pt-1 pb-4">
<p class="h2">Case Duration</p>
<ul class="space-y-1">
<li><Tag value="MIN" class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"></Tag>{{ timeLabel(stats.case_duration.min) }}</li>
<li><Tag value="AVG" class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"></Tag>{{ timeLabel(stats.case_duration.average
) }}</li>
<li><Tag value="MED" class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"></Tag>{{ timeLabel(stats.case_duration.median) }}</li>
<li><Tag value="MAX" class="!text-neutral-900 !bg-neutral-200 mr-2 !w-10"></Tag>{{ timeLabel(stats.case_duration.max) }}</li>
</ul>
</div>
</div>
<!-- header: insight -->
<div v-if="tab === 'insight'">
<div class="border-b-2 border-neutral-300 mb-4">
<p class="h2">Most frequent</p>
<ul class="list-disc ml-6">
<li>Activity:&nbsp;
<span class="text-primary break-words" v-for="(value, key) in insights.most_freq_tasks" :key="key">{{ value }}<span v-if="key !== insights.most_freq_tasks.length - 1" class="text-neutral-900">,&nbsp;</span>
</span>
</li>
<li>Inbound connections:&nbsp;
<span class="text-primary break-words" v-for="(value, key) in insights.most_freq_in" :key="key">{{ value }}<span v-if="key !== insights.most_freq_in.length - 1" class="text-neutral-900">,&nbsp;</span>
</span>
</li>
<li>Outbound connections:&nbsp;
<span class="text-primary break-words" v-for="(value, key) in insights.most_freq_out" :key="key">{{ value }}<span v-if="key !== insights.most_freq_out.length - 1" class="text-neutral-900">,&nbsp;</span>
</span>
</li>
</ul>
<p class="h2">Most time-consuming</p>
<ul class="list-disc ml-6 mb-4">
<li class="w-full">Activity:&nbsp;
<span class="text-primary break-words" v-for="(value, key) in insights.most_time_tasks" :key="key">{{ value }}<span v-if="key !== insights.most_time_tasks.length - 1" class="text-neutral-900">,&nbsp;</span>
</span>
</li>
<li>Connection:&nbsp;
<span class="text-primary break-words" v-for="(item, key) in insights.most_time_edges" :key="key">
<span v-for="(value, index) in item" :key="index">{{ value }}<span v-if="index !== item.length - 1">&nbsp;<span class="material-symbols-outlined text-lg align-sub ">arrow_forward</span>&nbsp;</span>
</span><span v-if="key !== insights.most_time_edges.length - 1" class="text-neutral-900">,&nbsp;</span>
</span>
</li>
</ul>
</div>
<div>
<ul class="text-neutral-500 grid grid-cols-2 gap-2 text-center text-sm font-medium mb-2">
<li class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500" @click="active1 = 0" :class="active1 === 0? 'text-primary border-primary':''">Self-loop</li>
<li class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500" @click="active1 = 1" :class="active1 === 1? 'text-primary border-primary':''">Short-loop</li>
<li class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500" @click="active1 = 2" :class="active1 === 2? 'text-primary border-primary':''">Shortest Trace</li>
<li class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500" @click="active1 = 3" :class="active1 === 3? 'text-primary border-primary':''">Longest Trace</li>
<li class="border border-neutral-500 rounded p-2 cursor-pointer hover:text-primary hover:border-primary hover:duration-500" @click="active1 = 4" :class="active1 === 4? 'text-primary border-primary':''">Most Frequent Trace</li>
</ul>
<div>
<TabView ref="tabview2" v-model:activeIndex="active1">
<TabPanel header="Self-loop">
<p v-if="insights.self_loops.length === 0">No data</p>
<ul v-else class="list-disc ml-6">
<li v-for="(value, key) in insights.self_loops" :key="key">
<span>{{ value }}</span>
</li>
</ul>
</TabPanel>
<TabPanel header="Short-loop">
<p v-if="insights.short_loops.length === 0">No data</p>
<ul v-else class="list-disc ml-6">
<li class="break-words" v-for="(item, key) in insights.short_loops" :key="key">
<span v-for="(value, index) in item" :key="index">{{ value }}<span v-if="index !== item.length - 1">&nbsp;<span class="material-symbols-outlined text-lg align-sub">sync_alt</span>&nbsp;</span>
</span>
</li>
</ul>
</TabPanel>
<TabPanel header="Shortest Trace">
<p v-if="insights.shortest_traces.length === 0">No data</p>
<ul v-else class="list-disc ml-6">
<li class="break-words" v-for="(item, key) in insights.shortest_traces" :key="key">
<span v-for="(value, index) in item" :key="index">{{ value }}<span v-if="index !== item.length - 1">&nbsp;<span class="material-symbols-outlined text-lg align-sub">arrow_forward</span>&nbsp;</span>
</span>
</li>
</ul>
</TabPanel>
<TabPanel header="Longest Trace">
<p v-if="insights.longest_traces.length === 0">No data</p>
<ul v-else class="list-disc ml-6">
<li class="break-words" v-for="(item, key) in insights.longest_traces" :key="key">
<span v-for="(value, index) in item" :key="index">{{ value }}<span v-if="index !== item.length - 1">&nbsp;<span class="material-symbols-outlined text-lg align-sub">arrow_forward</span>&nbsp;</span>
</span>
</li>
</ul>
</TabPanel>
<TabPanel header="Most Frequent Trace">
<li v-if="insights.most_freq_traces.length === 0">No data</li>
<ul v-else class="list-disc ml-6">
<li class="break-words" v-for="(item, key) in insights.most_freq_traces" :key="key">
<span v-for="(value, index) in item" :key="index">{{ value }}<span v-if="index !== item.length - 1">&nbsp;<span class="material-symbols-outlined text-lg align-sub">arrow_forward</span>&nbsp;</span>
</span>
</li>
</ul>
</TabPanel>
</TabView>
</div>
</div>
</div>
</Sidebar>
</template>
<script>
import getNumberLabel from '@/module/numberLabel.js';
import getTimeLabel from '@/module/timeLabel.js';
import getMoment from 'moment';
export default {
props:{
sidebarState: {
type: Boolean,
require: false,
},
stats: {
type: Object,
required: false,
},
insights: {
type: Object,
required: false,
}
},
data() {
return {
tab: 'summary',
valueCases: 0,
valueTraces: 0,
valueTaskInstances: 0,
valueTasks: 0,
active1: 0,
}
},
methods: {
/**
* @param {string} switch Summary or Insight
*/
switchTab(tab) {
this.tab = tab;
},
/**
* @param {number} time use timeLabel.js
*/
timeLabel(time){
return getTimeLabel(time);
},
/**
* @param {number} time use moment
*/
moment(time){
return getMoment(time).format('YYYY-MM-DD HH:mm');
},
/**
* @param {number} num use numberLabel.js
*/
numberLabel(num){
return getNumberLabel(num);
},
/**
* Number to percentage
* @param {number} val
* @returns {string} 轉換完成的百分比字串
*/
getPercentLabel(val){
if(val * 100 === 100) return `${val * 100}%`;
return `${(val * 100).toFixed(1)}%`;
},
/**
* Behavior when show
*/
show(){
this.valueCases = this.stats.cases.ratio * 100;
this.valueTraces= this.stats.traces.ratio * 100;
this.valueTaskInstances = this.stats.task_instances.ratio * 100;
this.valueTasks = this.stats.tasks.ratio * 100;
},
/**
* Behavior when hidden
*/
hide(){
this.valueCases = 0;
this.valueTraces= 0;
this.valueTaskInstances = 0;
this.valueTasks = 0;
},
},
}
</script>
<style scoped>
:deep(.p-progressbar .p-progressbar-value) {
@apply bg-primary
}
:deep(.p-tabview-nav-container) {
@apply hidden
}
:deep(.p-tabview-panels) {
@apply !bg-neutral-100 p-2 rounded
}
:deep(.p-tabview-panel) {
@apply animate-fadein
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<Sidebar :visible="sidebarTraces" :closeIcon="'pi pi-chevron-left'" :modal="false" position="left" :dismissable="true" class="!w-11/12" @show="show()">
<template #header>
<p class="h1">Traces</p>
</template>
<div class="pt-4 h-full flex items-center justify-start">
<!-- Trace List -->
<section class="w-80 h-full pr-4 border-r border-neutral-300">
<p class="h2 px-2 mb-2">Trace List ({{ traceTotal }})</p>
<p class="text-primary h2 px-2 mb-2">
<span class="material-symbols-outlined text-sm align-[-10%] mr-2">info</span>Click trace number to see more.
</p>
<div class="overflow-y-scroll overflow-x-hidden scrollbar mx-[-8px] max-h-[calc(100%_-_96px)]" >
<table class="border-separate border-spacing-x-2 text-sm">
<thead class="sticky top-0 z-10 bg-neutral-10">
<tr>
<th class="h2 px-2 border-b border-neutral-500">Trace</th>
<th class="h2 px-2 border-b border-neutral-500 text-start" colspan="3">Occurrences</th>
</tr>
</thead>
<tbody>
<tr v-for="(trace, key) in traceList" :key="key" class=" cursor-pointer hover:text-primary" @click="switchCaseData(trace.id)">
<td class="p-2">#{{ trace.id }}</td>
<td class="p-2 w-24">
<div class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden">
<div class="h-full bg-primary" :style="progressWidth(trace.value)"></div>
</div>
</td>
<td class="py-2 text-right">{{ trace.count }}</td>
<td class="p-2">{{ trace.ratio }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Trace item Table -->
<section class="pl-4 h-full w-[calc(100%_-_320px)]">
<p class="h2 mb-2">Trace #{{ showTraceId }}</p>
<div class="h-52 w-full px-2 mb-2 border border-neutral-300 rounded">
<div class="h-full w-full">
<div id="cyTrace" ref="cyTrace" class="h-full min-w-full relative"></div>
</div>
</div>
<div class="overflow-y-auto overflow-x-auto scrollbar h-[calc(100%_-_264px)]">
<DataTable :value="cases" showGridlines tableClass="text-sm" breakpoint="0">
<Column field="id" header="Case ID" sortable></Column>
<Column field="started_at" header="Start time" sortable></Column>
<Column field="completed_at" header="End time" sortable></Column>
</DataTable>
</div>
</section>
</div>
</Sidebar>
</template>
<script>
import cytoscapeMapTrace from '@/module/cytoscapeMapTrace.js';
export default {
props: {
sidebarTraces: Boolean,
traces: Array,
traceTaskSeq: Array,
cases: Array
},
data() {
return {
processMap:{
nodes:[],
edges:[],
},
showTraceId: 1,
}
},
computed: {
traceTotal: function() {
return this.traces.length;
},
traceList: function() {
return this.traces.map(trace => {
return {
id: trace.id,
value: Number((trace.ratio * 100).toFixed(1)),
count: trace.count,
ratio: this.getPercentLabel(trace.ratio),
};
}).sort((x, y) => x.id - y.id);
},
},
methods: {
/**
* Number to percentage
* @param {number} val
* @returns {string} 轉換完成的百分比字串
*/
getPercentLabel(val){
return (val * 100 === 100) ? `${val * 100}%` : `${(val * 100).toFixed(1)}%`;
},
/**
* set progress bar width
* @param {number} value
* @returns {string} 樣式的寬度設定
*/
progressWidth(value){
return `width:${value}%;`
},
/**
* switch case data
* @param {number} id
*/
async switchCaseData(id) {
this.showTraceId = id;
this.$emit('switch-Trace-Id', this.showTraceId);
},
/**
* 將 trace element nodes 資料彙整
*/
setNodesData(){
// 避免每次渲染都重複累加
this.processMap.nodes = [];
// 將 api call 回來的資料帶進 node
this.traceTaskSeq.forEach((node, index) => {
this.processMap.nodes.push({
data: {
id: index,
label: node,
backgroundColor: '#CCE5FF',
bordercolor: '#003366',
shape: 'round-rectangle',
height: 80,
width: 100
}
});
})
},
/**
* 將 trace edge line 資料彙整
*/
setEdgesData(){
this.processMap.edges = [];
this.traceTaskSeq.forEach((edge, index) => {
this.processMap.edges.push({
data: {
source: `${index}`,
target: `${index + 1}`,
lineWidth: 1,
style: 'solid'
}
});
});
// 關係線數量筆節點少一個
this.processMap.edges.pop();
},
/**
* create trace cytoscape's map
*/
createCy(){
let graphId = this.$refs.cyTrace;
this.setNodesData();
this.setEdgesData();
cytoscapeMapTrace(this.processMap.nodes, this.processMap.edges, graphId);
},
show() {
this.setNodesData();
this.setEdgesData();
this.createCy();
}
},
created(){
},
mounted() {
}
}
</script>
<style scoped>
/* 進度條顏色 */
:deep(.p-progressbar .p-progressbar-value) {
@apply bg-primary
}
/* Table set */
:deep(.p-datatable-thead th) {
@apply sticky top-0 left-0 z-10 bg-neutral-10
}
:deep(.p-datatable .p-datatable-thead > tr > th) {
@apply !border-y-0 border-neutral-500 bg-neutral-100 after:absolute after:left-0 after:w-full after:h-full after:block after:top-0 after:border-b after:border-t after:border-neutral-500
}
:deep(.p-datatable .p-datatable-tbody > tr > td) {
@apply border-neutral-500 !border-t-0
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<Sidebar :visible="sidebarView" :closeIcon="'pi pi-chevron-left'" :modal="false" position="left" :dismissable="true" >
<template #header>
<p class="h1">Visualization Setting</p>
</template>
<div>
<!-- View -->
<div class="my-4 border-b border-neutral-200">
<p class="h2">View</p>
<ul class="space-y-3 mb-4">
<!-- 選擇 bpmn / processmap button -->
<li class="btn-toggle-content">
<span class="btn-toggle-item" :class="mapType === 'processMap'?'btn-toggle-show ':''" @click="switchMapType('processMap')">
Process map
</span>
<span class="btn-toggle-item" :class="mapType === 'bpmn'?'btn-toggle-show':''" @click="switchMapType('bpmn')">
BPMN Model
</span>
</li>
<!-- 選擇繪畫樣式 bezier / unbundled-bezier button-->
<li class="btn-toggle-content">
<span class="btn-toggle-item" :class="curveStyle === 'unbundled-bezier'?'btn-toggle-show ':''" @click="switchCurveStyles('unbundled-bezier')">
Curved
</span>
<span class="btn-toggle-item" :class="curveStyle === 'taxi'?'btn-toggle-show':''" @click="switchCurveStyles('taxi')">
Elbow
</span>
</li>
<!-- 直向 TB | 橫向 LR -->
<li class="btn-toggle-content">
<span class="btn-toggle-item" :class="rank === 'LR'?'btn-toggle-show ':''" @click="switchRank('LR')">
Horizontal
</span>
<span class="btn-toggle-item" :class="rank === 'TB'?'btn-toggle-show':''" @click="switchRank('TB')">
Vertical
</span>
</li>
</ul>
</div>
<!-- Data Layer -->
<div>
<p class="h2">Data Layer</p>
<ul class="space-y-2">
<li class="flex justify-between mb-3" @change="switchDataLayerType($event, 'freq')">
<div class="flex items-center w-1/2">
<input type="radio" id="freq" value="freq" name="dataLayer" class="peer hidden" checked/>
<label for="freq" class="inline-block h-4 w-4 m-2 cursor-pointer rounded-full ring-2 ring-neutral-300 shadow-sm peer-checked:ring-2 peer-checked:ring-primary peer-checked:ring-offset-2 peer-checked:bg-primary">
</label>
<span class="inline-block ml-2">Frequency</span>
</div>
<div class="w-1/2">
<select class="border border-neutral-500 rounded p-1 w-full focus-visible:outline-primary" :disabled="dataLayerType === 'duration'">
<option v-for="(freq, index) in selectFrequency" :key="index" :value="freq.value" :disabled="freq.disabled" :selected="freq.value === selectedFreq">{{ freq.label }}</option>
</select>
</div>
</li>
<li class="flex justify-between mb-3" @change="switchDataLayerType($event, 'duration')">
<div class="flex items-center w-1/2">
<input type="radio" id="duration" value="duration" name="dataLayer" class="peer hidden" />
<label for="duration" class="inline-block h-4 w-4 m-2 cursor-pointer rounded-full ring-2 ring-neutral-300 shadow-sm peer-checked:ring-2 peer-checked:ring-primary peer-checked:ring-offset-2 peer-checked:bg-primary"></label>
<span class="inline-block ml-2">Duration</span>
</div>
<div class="w-1/2">
<select class="border border-neutral-500 rounded p-1 w-full focus-visible:outline-primary" :disabled="dataLayerType === 'freq'">
<option v-for="(duration, index) in selectDuration" :key="index" :value="duration.value" :disabled="duration.disabled" :selected="duration.value === selectedDuration">{{ duration.label }}</option>
</select>
</div>
</li>
</ul>
</div>
</div>
</Sidebar>
</template>
<script>
export default {
props: {
sidebarView: {
type: Boolean,
require: true,
},
},
data() {
return {
selectFrequency: [
{ value:"total", label:"Total", disabled:false, },
{ value:"rel_freq", label:"Relative", disabled:false, },
{ value:"average", label:"Average", disabled:false, },
{ value:"median", label:"Median", disabled:false, },
{ value:"max", label:"Max", disabled:false, },
{ value:"min", label:"Min", disabled:false, },
{ value:"cases", label:"Number of cases", disabled:false, },
],
selectDuration:[
{ value:"total", label:"Total", disabled:false, },
{ value:"rel_duration", label:"Relative", disabled:false, },
{ value:"average", label:"Average", disabled:false, },
{ value:"median", label:"Median", disabled:false, },
{ value:"max", label:"Max", disabled:false, },
{ value:"min", label:"Min", disabled:false, },
],
curveStyle:'unbundled-bezier', // unbundled-bezier | taxi
mapType: 'processMap', // processMap | bpmn
dataLayerType: 'freq', // freq | duration
dataLayerOption: 'total',
selectedFreq: '',
selectedDuration: '',
rank: 'LR', // 直向 TB | 橫向 LR
}
},
methods: {
/**
* switch map type
* @param {string} type processMap | bpmn
*/
switchMapType(type) {
this.mapType = type;
this.$emit('switch-map-type', this.mapType);
},
/**
* switch curve style
* @param {string} style 直角 unbundled-bezier | taxi
*/
switchCurveStyles(style) {
this.curveStyle = style;
this.$emit('switch-curve-styles', this.curveStyle);
},
/**
* switch rank
* @param {string} rank 直向 TB | 橫向 LR
*/
switchRank(rank) {
this.rank = rank;
this.$emit('switch-rank', this.rank);
},
/**
* switch Data Layoer Type or Option.
* @param {string} e
* @param {string} type freq | duration
*/
switchDataLayerType(e, type){
let value = e.target.type === 'select-one'? e.target.value: 'total';
switch (type) {
case 'freq':
this.dataLayerType = type;
this.dataLayerOption = value;
this.selectedFreq = value;
break;
case 'duration':
this.dataLayerType = type;
this.dataLayerOption = value;
this.selectedDuration = value;
break;
};
this.$emit('switch-data-layer-type', this.dataLayerType, this.dataLayerOption);
},
}
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<!-- Activity List -->
<div class="bg-neutral-10 border border-neutral-300 rounded-xl px-4 w-full h-full">
<div class="flex justify-between items-center my-2 flex-wrap">
<p class="h2">Activity List&nbsp({{ data.length }})</p>
</div>
<!-- Table -->
<div class="overflow-y-auto overflow-x-auto scrollbar -mx-2 h-[calc(100%_-_64px)]">
<table class="border-separate border-spacing-x-2 table-auto min-w-full text-sm" :class="data.length === 0? 'h-full': null">
<thead class="sticky top-0 left-0 z-10 bg-neutral-10">
<tr>
<th class="text-start font-semibold leading-10 px-2 border-b border-neutral-500">Activity</th>
<th class="font-semibold leading-10 px-2 border-b border-neutral-500 text-start" colspan="3">Occurrences</th>
</tr>
</thead>
<Draggable :list="data" group="people" itemKey="name" tag="tbody" animation="300" @end="onEnd">
<template #item="{ element, index }">
<tr>
<td class="px-4 py-2">{{ element.label }}</td>
<td class="px-4 py-2 w-24">
<div class="h-4 min-w-[96px] bg-neutral-300 rounded-sm overflow-hidden">
<div class="h-full bg-primary" :style="progressWidth(element.occ_value)"></div>
</div>
</td>
<td class="px-4 py-2 text-right">{{ element.occurrences }}</td>
<td class="px-4 py-2 text-right">{{ element.occurrence_ratio }}</td>
</tr>
</template>
</Draggable>
</table>
</div>
</div>
<!-- Sequence -->
<div class="bg-neutral-10 border border-neutral-300 rounded-xl px-4 pb-4 w-full h-full relative text-sm">
<p class="h2 border-b border-500 my-2">Sequence&nbsp({{ listSeq.length }})</p>
<!-- No Data -->
<div v-if="listSequence.length === 0" class="p-4 w-[calc(100%_-_32px)] h-5/6 flex justify-center items-center absolute">
<p class="text-neutral-500">Please drag and drop activity(s) here and sort.</p>
</div>
<!-- Have Data -->
<div class="py-4 m-auto w-full h-[calc(100%_-_56px)]">
<div class="w-full h-full overflow-y-auto overflow-x-auto scrollbar px-4 text-center">
<draggable class="h-full" :list="listSequence" group="people" itemKey="name" animation="300" @end="onEnd">
<template #item="{ element, index }">
<div>
<div class="w-full p-2 border border-primary rounded text-primary">
<span>{{ element.label }}</span>
</div>
<span v-show="index !== listSeq.length - 1" class="pi pi-chevron-down !text-lg inline-block py-2 "></span>
</div>
</template>
</draggable>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
filterTaskData: {
type: Array,
required: true,
},
progressWidth: {
type: Function,
required: false,
},
listSeq: {
type: Array,
required: true,
}
},
data() {
return {
listSequence: this.listSeq,
data: this.filterTaskData,
}
},
watch: {
listSeq(newval){
this.listSequence = newval;
},
filterTaskData(newval){
this.data = newval;
}
},
methods: {
onEnd() {
this.$emit('update:listSeq', this.listSequence);
}
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More