From 21bb873c640f18f26ab8e9748e8a4612524c620c Mon Sep 17 00:00:00 2001 From: dvvanessastoiber Date: Mon, 28 Aug 2023 09:27:47 +0200 Subject: [PATCH 1/8] prepare release 4.0.0 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cc0f7477c..2e2c3d7e7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "visyn_core", "description": "Core repository for datavisyn applications.", - "version": "3.1.1-SNAPSHOT", + "version": "4.0.0", "author": { "name": "datavisyn GmbH", "email": "contact@datavisyn.io", @@ -161,7 +161,7 @@ "react-highlight-words": "^0.17.0", "react-plotly.js": "^2.5.1", "use-deep-compare-effect": "^1.8.0", - "visyn_scripts": "git+ssh://git@github.com/datavisyn/visyn_scripts#develop" + "visyn_scripts": "^5.0.0" }, "devDependencies": { "@babel/core": "^7.17.7", From b5740ff8cb5c60085d5a93950312571a7c84e9d5 Mon Sep 17 00:00:00 2001 From: dvvanessastoiber Date: Mon, 28 Aug 2023 10:18:55 +0200 Subject: [PATCH 2/8] prepare next dev version --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2e2c3d7e7..038b62801 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "visyn_core", "description": "Core repository for datavisyn applications.", - "version": "4.0.0", + "version": "4.0.1-SNAPSHOT", "author": { "name": "datavisyn GmbH", "email": "contact@datavisyn.io", @@ -161,7 +161,7 @@ "react-highlight-words": "^0.17.0", "react-plotly.js": "^2.5.1", "use-deep-compare-effect": "^1.8.0", - "visyn_scripts": "^5.0.0" + "visyn_scripts": "git+ssh://git@github.com/datavisyn/visyn_scripts#develop" }, "devDependencies": { "@babel/core": "^7.17.7", From 2b3ae060feae5fa06d381904bffc6f6d9b5479ca Mon Sep 17 00:00:00 2001 From: Michael Puehringer Date: Thu, 31 Aug 2023 07:34:10 +0200 Subject: [PATCH 3/8] Update anyio to_thread import to make pyright happy --- visyn_core/server/visyn_server.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/visyn_core/server/visyn_server.py b/visyn_core/server/visyn_server.py index 3b0a0b97c..0dee41743 100644 --- a/visyn_core/server/visyn_server.py +++ b/visyn_core/server/visyn_server.py @@ -4,7 +4,6 @@ import threading from typing import Any -import anyio from fastapi import FastAPI from fastapi.middleware.wsgi import WSGIMiddleware from pydantic import create_model @@ -164,8 +163,15 @@ def filter(self, record: logging.LogRecord) -> bool: async def change_anyio_total_tokens(): # FastAPI uses anyio threads to handle sync endpoint concurrently. # This is a workaround to increase the number of threads to 100, as the default is only 40. - limiter = anyio.to_thread.current_default_thread_limiter() - limiter.total_tokens = manager.settings.visyn_core.total_anyio_tokens + try: + from anyio import to_thread + + limiter = to_thread.current_default_thread_limiter() + limiter.total_tokens = manager.settings.visyn_core.total_anyio_tokens + except Exception as e: + _log.exception( + f"Could not set the total number of anyio tokens to {manager.settings.visyn_core.total_anyio_tokens}. Error: {e}" + ) if manager.settings.visyn_core.cypress: _log.info("Cypress mode is enabled. This should only be used in a Cypress testing environment or CI.") From a8fd3dc8b5fd8f7736b4cc1a7561f9dbc5175949 Mon Sep 17 00:00:00 2001 From: Champari Oltion <51322092+oltionchampari@users.noreply.github.com> Date: Thu, 7 Sep 2023 11:21:30 +0200 Subject: [PATCH 4/8] Set formatter language to English in fromNow util function (#83) --- src/utils/fromNow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/fromNow.ts b/src/utils/fromNow.ts index 8d97b69f8..681656893 100644 --- a/src/utils/fromNow.ts +++ b/src/utils/fromNow.ts @@ -1,4 +1,4 @@ -const formatter = new Intl.RelativeTimeFormat(undefined, { +const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto', }); From 86c09c81074c2479260dedb265129e31af4bcc3c Mon Sep 17 00:00:00 2001 From: Christian Bors <84898946+dvchristianbors@users.noreply.github.com> Date: Tue, 26 Sep 2023 09:41:58 +0200 Subject: [PATCH 5/8] docs: Add oauth2/keycloak test environment (#86) Co-authored-by: Holger Stitz --- deploy/oauth/README.md | 43 + deploy/oauth/docker-compose.yaml | 39 + deploy/oauth/keycloak/master-realm.json | 1686 +++++++++++++++++++++ deploy/oauth/keycloak/master-users-0.json | 31 + deploy/oauth/oauth2-proxy.cfg | 25 + 5 files changed, 1824 insertions(+) create mode 100644 deploy/oauth/README.md create mode 100644 deploy/oauth/docker-compose.yaml create mode 100644 deploy/oauth/keycloak/master-realm.json create mode 100644 deploy/oauth/keycloak/master-users-0.json create mode 100644 deploy/oauth/oauth2-proxy.cfg diff --git a/deploy/oauth/README.md b/deploy/oauth/README.md new file mode 100644 index 000000000..6c824b292 --- /dev/null +++ b/deploy/oauth/README.md @@ -0,0 +1,43 @@ +## Oauth2-Proxy + +Used to locally, manually test te behaviour of auth-reverse-proxies like ALB. + +Based on the suggested docker images from the main [oauth2-proxy environment](https://github.com/oauth2-proxy/oauth2-proxy/tree/master/contrib/local-environment) + +### Pre-requisites + +Add this to your /etc/hosts (uncommented) + +``` +127.0.0.1 keycloak +127.0.0.1 oauth2-proxy +``` + +Credentials | admin/password +Email | admin@datavisyn.io + +# Configuration + +Frontend env (common env in dev, different in prod) via webpack dotenv + +## LOGOUT_URL + +The logout url consists of multiple redirects. First we need to perform the oauth2 proxy sign out via calling `/oauth2/sign_out`. +This sign out procedure deletes the oauth2 cookie and redirects to the open id connect provider via `rd=...`. The redirect url must be an encoded url. +In our local test case, the redirect url will then point to keycloak's openid-connect logout: `auth/realms/{realm}/protocol/openid-connect/logout`. This ensures that the oidc provider also knows that the user should be logged out to prevent automatic redirects to the app with a valid cookie. + +``` +?rd=http://keycloak:9080/auth/realms/master/protocol/openid-connect/logout?redirect_uri=http://localhost:4180 +``` + +- REFRESH_URL + +Backend env + +```env +VISYN_CORE__SECURITY__STORE__OAUTH2_SECURITY_STORE__ENABLE=true +VISYN_CORE__SECURITY__STORE__OAUTH2_SECURITY_STORE__ACCESS_TOKEN_HEADER_NAME=x-forwarded-access-token +VISYN_CORE__SECURITY__STORE__OAUTH2_SECURITY_STORE__COOKIE_NAME=_oauth2_proxy +VISYN_CORE__SECURITY__STORE__OAUTH2_SECURITY_STORE__SIGNOUT_URL=http://localhost:4180/oauth2/sign_out?rd=http%3A%2F%2Fkeycloak%3A9080%2Fauth%2Frealms%2Fmaster%2Fprotocol%2Fopenid-connect%2Flogout%3Fredirect_uri%3Dhttp%3A%2F%2Flocalhost%3A4180 + +``` diff --git a/deploy/oauth/docker-compose.yaml b/deploy/oauth/docker-compose.yaml new file mode 100644 index 000000000..bc89472e9 --- /dev/null +++ b/deploy/oauth/docker-compose.yaml @@ -0,0 +1,39 @@ +# Simplified variant of https://github.com/oauth2-proxy/oauth2-proxy/blob/master/contrib/local-environment/docker-compose-keycloak.yaml +version: '3.0' +services: + oauth2-proxy: + container_name: oauth2-proxy + image: quay.io/oauth2-proxy/oauth2-proxy:v7.3.0 + command: --config /oauth2-proxy.cfg + hostname: oauth2-proxy + volumes: + - './oauth2-proxy.cfg:/oauth2-proxy.cfg' + restart: unless-stopped + depends_on: + - keycloak + network_mode: host + # required for oauth2-proxy.cfg - upstreams localhost, as the frontend isn't available inside docker + + keycloak: + container_name: keycloak + image: jboss/keycloak:10.0.0 + hostname: keycloak + command: + [ + '-Djboss.socket.binding.port-offset=1000', + '-Dkeycloak.migration.action=import', + '-Dkeycloak.migration.provider=dir', + '-Dkeycloak.migration.dir=/realm-config', + '-Dkeycloak.migration.strategy=IGNORE_EXISTING', + ] + volumes: + - ./keycloak:/realm-config + environment: + KEYCLOAK_USER: admin + KEYCLOAK_PASSWORD: password + ports: + - 9080:9080/tcp +# this docker image REALLY doesn't like to run in network-mode host +# requires this in your /etc/hosts (uncommented) +# 127.0.0.1 keycloak +# 127.0.0.1 oauth2-proxy diff --git a/deploy/oauth/keycloak/master-realm.json b/deploy/oauth/keycloak/master-realm.json new file mode 100644 index 000000000..7c1b3d986 --- /dev/null +++ b/deploy/oauth/keycloak/master-realm.json @@ -0,0 +1,1686 @@ +{ + "id" : "master", + "realm" : "master", + "displayName" : "Keycloak", + "displayNameHtml" : "
Keycloak
", + "notBefore" : 0, + "revokeRefreshToken" : false, + "refreshTokenMaxReuse" : 0, + "accessTokenLifespan" : 36000, + "accessTokenLifespanForImplicitFlow" : 36000, + "ssoSessionIdleTimeout" : 36000, + "ssoSessionMaxLifespan" : 36000, + "ssoSessionIdleTimeoutRememberMe" : 0, + "ssoSessionMaxLifespanRememberMe" : 0, + "offlineSessionIdleTimeout" : 2592000, + "offlineSessionMaxLifespanEnabled" : false, + "offlineSessionMaxLifespan" : 5184000, + "clientSessionIdleTimeout" : 0, + "clientSessionMaxLifespan" : 0, + "accessCodeLifespan" : 36000, + "accessCodeLifespanUserAction" : 36000, + "accessCodeLifespanLogin" : 36000, + "actionTokenGeneratedByAdminLifespan" : 43200, + "actionTokenGeneratedByUserLifespan" : 36000, + "enabled" : true, + "sslRequired" : "external", + "registrationAllowed" : false, + "registrationEmailAsUsername" : false, + "rememberMe" : false, + "verifyEmail" : false, + "loginWithEmailAllowed" : true, + "duplicateEmailsAllowed" : false, + "resetPasswordAllowed" : false, + "editUsernameAllowed" : false, + "bruteForceProtected" : false, + "permanentLockout" : false, + "maxFailureWaitSeconds" : 900, + "minimumQuickLoginWaitSeconds" : 60, + "waitIncrementSeconds" : 60, + "quickLoginCheckMilliSeconds" : 1000, + "maxDeltaTimeSeconds" : 43200, + "failureFactor" : 30, + "roles" : { + "realm" : [ { + "id" : "32626c92-4327-40f1-b318-76a6b5c7eee5", + "name" : "offline_access", + "description" : "${role_offline-access}", + "composite" : false, + "clientRole" : false, + "containerId" : "master", + "attributes" : { } + }, { + "id" : "e36da570-7ae0-4323-8b39-73eb92ce722f", + "name" : "admin", + "description" : "${role_admin}", + "composite" : true, + "composites" : { + "realm" : [ "create-realm" ], + "client" : { + "master-realm" : [ "query-groups", "create-client", "query-realms", "view-authorization", "view-realm", "manage-clients", "query-users", "manage-realm", "view-events", "manage-events", "view-identity-providers", "view-users", "manage-identity-providers", "manage-authorization", "manage-users", "view-clients", "query-clients", "impersonation" ] + } + }, + "clientRole" : false, + "containerId" : "master", + "attributes" : { } + }, { + "id" : "71aca46c-6fcf-4456-ba87-6374e70108a2", + "name" : "uma_authorization", + "description" : "${role_uma_authorization}", + "composite" : false, + "clientRole" : false, + "containerId" : "master", + "attributes" : { } + }, { + "id" : "6ca3fee8-1a3f-4068-a311-6e81223a884b", + "name" : "create-realm", + "description" : "${role_create-realm}", + "composite" : false, + "clientRole" : false, + "containerId" : "master", + "attributes" : { } + } ], + "client" : { + "oauth2-proxy" : [ ], + "security-admin-console" : [ ], + "admin-cli" : [ ], + "account-console" : [ ], + "broker" : [ { + "id" : "2cc5e40c-0a28-4c09-85eb-20cd47ac1351", + "name" : "read-token", + "description" : "${role_read-token}", + "composite" : false, + "clientRole" : true, + "containerId" : "380985f1-61c7-4940-93ae-7a09458071ca", + "attributes" : { } + } ], + "master-realm" : [ { + "id" : "a8271c2c-6437-4ca5-ae83-49ea5fe1318d", + "name" : "query-groups", + "description" : "${role_query-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "5a7cb1ae-7dac-486b-bf7b-4d7fbc5adb31", + "name" : "create-client", + "description" : "${role_create-client}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "a9e6a2fa-c31b-4959-bf8a-a46fcc9c65ec", + "name" : "view-authorization", + "description" : "${role_view-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "1cef34e3-569a-4d2b-ba5c-aafe5c7ab423", + "name" : "query-realms", + "description" : "${role_query-realms}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "efc46075-30cd-4600-aa92-2ae4a171d0c2", + "name" : "view-realm", + "description" : "${role_view-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "9ffacaf0-afc6-49e9-8708-ef35ac40f3f8", + "name" : "manage-clients", + "description" : "${role_manage-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "90662091-b3bc-4ae4-83c9-a4f53e7e9eeb", + "name" : "query-users", + "description" : "${role_query-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "9a5fbc9d-6fae-4155-86f6-72fd399aa126", + "name" : "manage-realm", + "description" : "${role_manage-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "03f46127-9436-477d-8c7f-58569f45237c", + "name" : "view-events", + "description" : "${role_view-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "f10eaea2-90ab-4310-9d5f-8d986564d061", + "name" : "view-identity-providers", + "description" : "${role_view-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "2403e038-2cf7-4b06-b5cb-33a417a00d8d", + "name" : "manage-events", + "description" : "${role_manage-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "677d057b-66f8-4163-9948-95fdbd06dfdc", + "name" : "view-users", + "description" : "${role_view-users}", + "composite" : true, + "composites" : { + "client" : { + "master-realm" : [ "query-groups", "query-users" ] + } + }, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "dc140fa6-bf2c-49f2-b8c9-fc34ef8a2c63", + "name" : "manage-identity-providers", + "description" : "${role_manage-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "155bf234-4895-4855-95c2-a460518f57e8", + "name" : "manage-authorization", + "description" : "${role_manage-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "5441ec71-3eac-4696-9e68-0de54fbdde98", + "name" : "manage-users", + "description" : "${role_manage-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "2db0f052-cb91-4170-81fd-107756b162f7", + "name" : "view-clients", + "description" : "${role_view-clients}", + "composite" : true, + "composites" : { + "client" : { + "master-realm" : [ "query-clients" ] + } + }, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "e1d7f235-8bf2-40b8-be49-49aca70a5088", + "name" : "query-clients", + "description" : "${role_query-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + }, { + "id" : "e743f66a-2f56-4b97-b34b-33f06ff1e739", + "name" : "impersonation", + "description" : "${role_impersonation}", + "composite" : false, + "clientRole" : true, + "containerId" : "7174c175-1887-4e57-b95b-969fe040deff", + "attributes" : { } + } ], + "account" : [ { + "id" : "64d8f532-839e-4386-b2eb-fe8848b0a9de", + "name" : "manage-consent", + "description" : "${role_manage-consent}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "view-consent" ] + } + }, + "clientRole" : true, + "containerId" : "a367038f-fe01-4459-9f91-7ad0cf498533", + "attributes" : { } + }, { + "id" : "3ec22748-960f-4f96-a43e-50f54a02dc23", + "name" : "view-profile", + "description" : "${role_view-profile}", + "composite" : false, + "clientRole" : true, + "containerId" : "a367038f-fe01-4459-9f91-7ad0cf498533", + "attributes" : { } + }, { + "id" : "177d18e4-46b0-4ea3-8b70-327486ce5bb2", + "name" : "view-applications", + "description" : "${role_view-applications}", + "composite" : false, + "clientRole" : true, + "containerId" : "a367038f-fe01-4459-9f91-7ad0cf498533", + "attributes" : { } + }, { + "id" : "703643d6-0542-4e27-9737-7c442925c18c", + "name" : "manage-account-links", + "description" : "${role_manage-account-links}", + "composite" : false, + "clientRole" : true, + "containerId" : "a367038f-fe01-4459-9f91-7ad0cf498533", + "attributes" : { } + }, { + "id" : "c64f9f66-d762-4337-8833-cf31c316e8a7", + "name" : "view-consent", + "description" : "${role_view-consent}", + "composite" : false, + "clientRole" : true, + "containerId" : "a367038f-fe01-4459-9f91-7ad0cf498533", + "attributes" : { } + }, { + "id" : "611f568b-0fdd-4d2e-ba34-03136cd486c4", + "name" : "manage-account", + "description" : "${role_manage-account}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "manage-account-links" ] + } + }, + "clientRole" : true, + "containerId" : "a367038f-fe01-4459-9f91-7ad0cf498533", + "attributes" : { } + } ] + } + }, + "groups" : [ ], + "defaultRoles" : [ "offline_access", "uma_authorization" ], + "requiredCredentials" : [ "password" ], + "otpPolicyType" : "totp", + "otpPolicyAlgorithm" : "HmacSHA1", + "otpPolicyInitialCounter" : 0, + "otpPolicyDigits" : 6, + "otpPolicyLookAheadWindow" : 1, + "otpPolicyPeriod" : 30, + "otpSupportedApplications" : [ "FreeOTP", "Google Authenticator" ], + "webAuthnPolicyRpEntityName" : "keycloak", + "webAuthnPolicySignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyRpId" : "", + "webAuthnPolicyAttestationConveyancePreference" : "not specified", + "webAuthnPolicyAuthenticatorAttachment" : "not specified", + "webAuthnPolicyRequireResidentKey" : "not specified", + "webAuthnPolicyUserVerificationRequirement" : "not specified", + "webAuthnPolicyCreateTimeout" : 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyAcceptableAaguids" : [ ], + "webAuthnPolicyPasswordlessRpEntityName" : "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyPasswordlessRpId" : "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference" : "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment" : "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey" : "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement" : "not specified", + "webAuthnPolicyPasswordlessCreateTimeout" : 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], + "scopeMappings" : [ { + "clientScope" : "offline_access", + "roles" : [ "offline_access" ] + } ], + "clientScopeMappings" : { + "account" : [ { + "client" : "account-console", + "roles" : [ "manage-account" ] + } ] + }, + "clients" : [ { + "id" : "a367038f-fe01-4459-9f91-7ad0cf498533", + "clientId" : "account", + "name" : "${client_account}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/master/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "0896a464-da81-4454-bee9-b56bdbad9e7f", + "defaultRoles" : [ "view-profile", "manage-account" ], + "redirectUris" : [ "/realms/master/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "72f75604-1e21-407c-b967-790aafd11534", + "clientId" : "account-console", + "name" : "${client_account-console}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/master/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "91f85142-ee18-4e30-9949-e5acb701bdee", + "redirectUris" : [ "/realms/master/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "2772c101-0dba-49b7-9627-5aaddc666939", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + } ], + "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "b13fd0de-3be0-4a08-bc5d-d1de34421b1a", + "clientId" : "admin-cli", + "name" : "${client_admin-cli}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "4640af2e-b4a6-44eb-85ec-6278a62a4f01", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : false, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "380985f1-61c7-4940-93ae-7a09458071ca", + "clientId" : "broker", + "name" : "${client_broker}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "65d2ba2b-bcae-49ff-9f56-77c818f55930", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "7174c175-1887-4e57-b95b-969fe040deff", + "clientId" : "master-realm", + "name" : "master Realm", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "40f73851-a94c-4091-90de-aeee8ca1acf8", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, + { + "id": "0493c7c6-6e20-49ea-9acb-627c0b52d400", + "clientId": "oauth2-proxy", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "72341b6d-7065-4518-a0e4-50ee15025608", + "redirectUris": [ + "http://localhost:4180/oauth2/callback", + "http://localhost:4180" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, { + "id" : "2a3ad1fd-a30d-4b72-89c4-bed12f178338", + "clientId" : "security-admin-console", + "name" : "${client_security-admin-console}", + "rootUrl" : "${authAdminUrl}", + "baseUrl" : "/admin/master/console/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "b234b7aa-8417-410f-b3fd-c57434d3aa4a", + "redirectUris" : [ "/admin/master/console/*" ], + "webOrigins" : [ "+" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "5885b0d3-a917-4b52-8380-f37d0754a2ef", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + } ], + "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + } ], + "clientScopes" : [ { + "id" : "47ea3b67-4f0c-4c7e-8ac6-a33a3d655894", + "name" : "address", + "description" : "OpenID Connect built-in scope: address", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${addressScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "4be0ca19-0ec7-4cc1-b263-845ea539ff12", + "name" : "address", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-address-mapper", + "consentRequired" : false, + "config" : { + "user.attribute.formatted" : "formatted", + "user.attribute.country" : "country", + "user.attribute.postal_code" : "postal_code", + "userinfo.token.claim" : "true", + "user.attribute.street" : "street", + "id.token.claim" : "true", + "user.attribute.region" : "region", + "access.token.claim" : "true", + "user.attribute.locality" : "locality" + } + } ] + }, { + "id" : "aba72e57-540f-4825-95b7-2d143be028cc", + "name" : "email", + "description" : "OpenID Connect built-in scope: email", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${emailScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "7fe82724-5748-4b6d-9708-a028f5d3b970", + "name" : "email verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "emailVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email_verified", + "jsonType.label" : "boolean" + } + }, { + "id" : "e42f334e-cfae-44a0-905d-c3ef215feaae", + "name" : "email", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "email", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "ec765598-bd71-4318-86c3-b3f81a41c99e", + "name" : "microprofile-jwt", + "description" : "Microprofile - JWT built-in scope", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "90694036-4014-4672-a2c8-c68319e9308a", + "name" : "upn", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "upn", + "jsonType.label" : "String" + } + }, { + "id" : "f7b0fcc0-6139-4158-ac45-34fd9a58a5ef", + "name" : "groups", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "multivalued" : "true", + "user.attribute" : "foo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "groups", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "8a09267b-3634-4a9c-baab-6f2fb4137347", + "name" : "offline_access", + "description" : "OpenID Connect built-in scope: offline_access", + "protocol" : "openid-connect", + "attributes" : { + "consent.screen.text" : "${offlineAccessScopeConsentText}", + "display.on.consent.screen" : "true" + } + }, { + "id" : "3a48c5dd-33a8-4be0-9d2e-30fd7f98363a", + "name" : "phone", + "description" : "OpenID Connect built-in scope: phone", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${phoneScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "5427d1b4-ba79-412a-b23c-da640a98980c", + "name" : "phone number", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumber", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number", + "jsonType.label" : "String" + } + }, { + "id" : "31d4a53f-6503-40e8-bd9d-79a7c46c4fbe", + "name" : "phone number verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumberVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number_verified", + "jsonType.label" : "boolean" + } + } ] + }, { + "id" : "5921a9e9-7fec-4471-95e3-dd96eebdec58", + "name" : "profile", + "description" : "OpenID Connect built-in scope: profile", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${profileScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "4fa92092-ee0d-4dc7-a63b-1e3b02d35ebb", + "name" : "zoneinfo", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "zoneinfo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "zoneinfo", + "jsonType.label" : "String" + } + }, { + "id" : "1a5cc2e2-c983-4150-8583-23a7f5c826bf", + "name" : "family name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "lastName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "family_name", + "jsonType.label" : "String" + } + }, { + "id" : "67931f77-722a-492d-b581-a953e26b7d44", + "name" : "full name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-full-name-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "access.token.claim" : "true", + "userinfo.token.claim" : "true" + } + }, { + "id" : "10f6ac36-3a63-4e1c-ac69-c095588f5967", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + }, { + "id" : "205d9dce-b6c8-4b1d-9c9c-fa24788651cf", + "name" : "picture", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "picture", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "picture", + "jsonType.label" : "String" + } + }, { + "id" : "638216c8-ea8c-40e3-9429-771e9278920e", + "name" : "gender", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "gender", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "gender", + "jsonType.label" : "String" + } + }, { + "id" : "39c17eae-8ea7-422c-ae21-b8876bf12184", + "name" : "birthdate", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "birthdate", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "birthdate", + "jsonType.label" : "String" + } + }, { + "id" : "01c559cf-94f2-46ad-b965-3b2e1db1a2a6", + "name" : "updated at", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "updatedAt", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "updated_at", + "jsonType.label" : "String" + } + }, { + "id" : "1693b5ab-28eb-485d-835d-2ae070ccb3ba", + "name" : "profile", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "profile", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "profile", + "jsonType.label" : "String" + } + }, { + "id" : "a0e08332-954c-46d2-9795-56eb31132580", + "name" : "given name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "firstName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "given_name", + "jsonType.label" : "String" + } + }, { + "id" : "cea0cd9c-d085-4d19-acc3-4bb41c891b68", + "name" : "nickname", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "nickname", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "nickname", + "jsonType.label" : "String" + } + }, { + "id" : "3122097d-4cba-46c2-8b3b-5d87a4cc605e", + "name" : "middle name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "middleName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "middle_name", + "jsonType.label" : "String" + } + }, { + "id" : "a3b97897-d913-4e0a-a4cf-033ce78f7d24", + "name" : "username", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "preferred_username", + "jsonType.label" : "String" + } + }, { + "id" : "a44eeb9d-410d-49c5-b0e0-5d84787627ad", + "name" : "website", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "website", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "website", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "651408a7-6704-4198-a60f-988821b633ea", + "name" : "role_list", + "description" : "SAML role list", + "protocol" : "saml", + "attributes" : { + "consent.screen.text" : "${samlRoleListScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "a8c56c7b-ccbc-4b01-8df5-3ecb6328755f", + "name" : "role list", + "protocol" : "saml", + "protocolMapper" : "saml-role-list-mapper", + "consentRequired" : false, + "config" : { + "single" : "false", + "attribute.nameformat" : "Basic", + "attribute.name" : "Role" + } + } ] + }, { + "id" : "13ec0fd3-e64a-4d6f-9be7-c8760f2c9d6b", + "name" : "roles", + "description" : "OpenID Connect scope for add user roles to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${rolesScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "75e741f8-dcd5-49d2-815e-8604ec1d08a1", + "name" : "realm roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "access.token.claim" : "true", + "claim.name" : "realm_access.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + }, { + "id" : "06a2d506-4996-4a33-8c43-2cf64af6a630", + "name" : "client roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-client-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "access.token.claim" : "true", + "claim.name" : "resource_access.${client_id}.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + }, { + "id" : "3c3470df-d414-4e1c-87fc-3fb3cea34b8d", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + } ] + }, { + "id" : "d85aba25-c74b-49e3-9ccb-77b4bb16efa5", + "name" : "web-origins", + "description" : "OpenID Connect scope for add allowed web origins to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false", + "consent.screen.text" : "" + }, + "protocolMappers" : [ { + "id" : "86b3f64f-1525-4500-bcbc-9b889b25f995", + "name" : "allowed web origins", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-allowed-origins-mapper", + "consentRequired" : false, + "config" : { } + } ] + } ], + "defaultDefaultClientScopes" : [ "roles", "profile", "role_list", "email", "web-origins" ], + "defaultOptionalClientScopes" : [ "phone", "address", "offline_access", "microprofile-jwt" ], + "browserSecurityHeaders" : { + "contentSecurityPolicyReportOnly" : "", + "xContentTypeOptions" : "nosniff", + "xRobotsTag" : "none", + "xFrameOptions" : "SAMEORIGIN", + "xXSSProtection" : "1; mode=block", + "contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity" : "max-age=31536000; includeSubDomains" + }, + "smtpServer" : { }, + "eventsEnabled" : false, + "eventsListeners" : [ "jboss-logging" ], + "enabledEventTypes" : [ ], + "adminEventsEnabled" : false, + "adminEventsDetailsEnabled" : false, + "components" : { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { + "id" : "59048b39-ad0f-4d12-8c52-7cfc2c43278a", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-full-name-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper" ] + } + }, { + "id" : "760559a6-a59f-4175-9ac5-6f3612e20129", + "name" : "Trusted Hosts", + "providerId" : "trusted-hosts", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "host-sending-registration-request-must-match" : [ "true" ], + "client-uris-must-match" : [ "true" ] + } + }, { + "id" : "24f4cb42-76bd-499e-812a-4e0d270c9e13", + "name" : "Full Scope Disabled", + "providerId" : "scope", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "abbfc599-480a-44ef-8e33-73a83eaab166", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "saml-user-property-mapper", "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper", "oidc-address-mapper" ] + } + }, { + "id" : "3c6450f0-4521-402b-a247-c8165854b1fa", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + }, { + "id" : "d9b64399-744b-498e-9d35-f68b1582bd7d", + "name" : "Consent Required", + "providerId" : "consent-required", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "22f15f1f-3116-4348-a1e5-fc0d7576452a", + "name" : "Max Clients Limit", + "providerId" : "max-clients", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "max-clients" : [ "200" ] + } + }, { + "id" : "4ad7b291-ddbb-4674-8c3d-ab8fd76d4168", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + } ], + "org.keycloak.keys.KeyProvider" : [ { + "id" : "f71cc325-9907-4d27-a0e6-88fca7450e5e", + "name" : "aes-generated", + "providerId" : "aes-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "6c7d982e-372f-49c6-a4f3-5c451fb85eca" ], + "secret" : [ "yH6M3W7aOgh2_cKJ0srWbw" ], + "priority" : [ "100" ] + } + }, { + "id" : "7b50d0ab-dda5-4624-aa42-b4b397724ce1", + "name" : "hmac-generated", + "providerId" : "hmac-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "587f0fb5-845d-4b45-87a0-84145092aaef" ], + "secret" : [ "PuH8Lxh9GeNfGJRDk34SWIlBDdrJpC3U3SfcxqqQtlIf2DBzRKUu8VbDVrmMN5b5CoPsJhrQ2SVb-iE9Lzsb3A" ], + "priority" : [ "100" ], + "algorithm" : [ "HS256" ] + } + }, { + "id" : "547c1c71-9f97-4e12-801b-ed5c2cc61bba", + "name" : "rsa-generated", + "providerId" : "rsa-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEowIBAAKCAQEAjdo2HZ5ruNnIbkSeAfFYpbPvJw3vtz/VuKJerC4mUXYd7qRMhs3VLJZ3mFyeCuO8W81vkGrFiC9KQnX2lHj2dtA/RWEJw5bpz+JdOFr5pvXg0lQ0sa+hro9afWDygTU4FmLsEi5z98847TbH178RT6n7+JVqZ9jYU9rSpwVTC8E/4yxSuStmhGCcAkZ6dGhHNBdvGUgwxKYj7dYLRJiI+nilIdKuxPzxI/YZxZnXBHDdbNXJgDymTQPut99OnBxeZbH38CJ1MNo3VdV1fzOMGUHe+vn/EOD5E+pXC8PwvJnWU+XHUTFVZeyIXehh3pYLUsq/6bZ1MYsEaFIhznOkwwIDAQABAoIBAHB+64fVyUxRurhoRn737fuLlU/9p2xGfbHtYvNdrhnQeLB3MBGAT10K/1Gfsd6k+Q49AAsiAgGcr2HBt4nL3HohcOwOpvWsS0UIGjHFRFP6jw9+pEN+K9UJ7xObvPZnRFHMpbdNi76tYlINrbMV3h61ihR8OmSc/gKSeZjnihK5OkaNnlqGRaBM/koI+iAxUHuJPnBLBZmD4T8eIfE4S2TvUeVeQogI9Muvnb9tIPJ5XyP9iXWLdRjnek/+wTdxHHZuo06Tc0bMjRaTHiF6K9ntOM2EmQb6bS2J47zgzRLNFE22BWH7RJq659EzElkOn0C0k7dWDTur/3Lpx1+zxJECgYEA8t+J3J+9oGTFmY2VPH05Yr/R/iVDOyIOlO1CmgonOQ3KPhbfNBB3aTAJP20LOZChN4JoWuiZJg4UwzXOeY9DvdDkPO0YLlSjPAIwJNk+xcxFcp5hqMUul2db+cgEY8zp0Wg9kFOq3JmJjK4+1+fgsVnOB+B08ZYI6bZzsUVKzucCgYEAlYTrsxs6fQua0cvZNQPYNZzwF3LVwPBl/ntkdRBE3HGyZeCAhIj7e9pAUusCPsQJaCmW2UEmywD/aIxGrBkojzTKItshM3PN1PYKL8W0Zq+H67uF5KfdvsbabZWHfP/LGCpoKF8Ov7JVPPqGrZ03Z2SheeLZAtNeHN4OB1u9i8UCgYATkS7qN3Rvl67T0DRVy0D0U7/3Wckw2m2SUgsrneXLEvFYTz9sUmdMcjJMidx9pslWT4tYx6SPDFNf5tXbtU8f29SHlBJ+qRL9oq9+SIJmLS7rLRdxIXG/gPRIC3VPFRNBa8SJ/DOn0jbivqcRffz8TN/sgojpbc0KB0kK3ypHwQKBgCKVCcb1R0PgyUA4+9YNO5a647UotFPZxl1jwMpqpuKt0WtKz67X2AK/ah1DidNmmB5lcCRzsztE0c4mk7n+X6kvtoj1UeqKoFLfTV/bRGxzsOZPCxrl0J3tdFvgN+QrbZf7Rvf/dHPWFWzzLO8+66+YUNjWJQdIR/45Rdlh2KdZAoGBAMfF3ir+fe3KdQ6hAf9QyrLxJ5l+GO+IgtxXGbon7eeJBIZHHdMeDy4pC7DMcI214BmIntbyY+xS+gI3oM26EJUVmrZ6tkyIDFsCHm9rcXG9ogvffzQWM1Wqzm27hR/3s+EPWW9AOcIimiFV1UPp/mLjnrCuq58V2aJS/TT14oLe" ], + "certificate" : [ "MIICmzCCAYMCBgFygL/j4DANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjAwNjA0MTkxMDU4WhcNMzAwNjA0MTkxMjM4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCN2jYdnmu42chuRJ4B8Vils+8nDe+3P9W4ol6sLiZRdh3upEyGzdUslneYXJ4K47xbzW+QasWIL0pCdfaUePZ20D9FYQnDlunP4l04Wvmm9eDSVDSxr6Guj1p9YPKBNTgWYuwSLnP3zzjtNsfXvxFPqfv4lWpn2NhT2tKnBVMLwT/jLFK5K2aEYJwCRnp0aEc0F28ZSDDEpiPt1gtEmIj6eKUh0q7E/PEj9hnFmdcEcN1s1cmAPKZNA+63306cHF5lsffwInUw2jdV1XV/M4wZQd76+f8Q4PkT6lcLw/C8mdZT5cdRMVVl7Ihd6GHelgtSyr/ptnUxiwRoUiHOc6TDAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAIAqydMYxa51kNEyfXyR2kStlglE4LDeLBLHDABeBPE0eN2awoH/mw3kXS4OA/C0e3c7bAwViOzOVERGeUNiBvP5rL1Amuu97nwFcxhkTaJH4ZwCGkxceaIo9LNDpAEesqHLQSdplFXIA4TbEFoKMem4k31KVU7i9/rUesrSRmxLptIOK7LLvRMYiY/t7tdAvoZAtoliuQlFKQywEuxXQrCkcoVEAARABWGt0rsWC2xK0tVxHRIrENwvMp/aUYd17sZ0403aaS9dlvfQ63ExnaHd+++RJtPku8P220Tw27YVmFAwzJgS0aUpEaDsgRNz6OMSyxEg/n7eKK08aU3szwQ=" ], + "priority" : [ "100" ] + } + } ] + }, + "internationalizationEnabled" : false, + "supportedLocales" : [ ], + "authenticationFlows" : [ { + "id" : "3253f9b7-905d-4458-ad8a-8ada5e16d195", + "alias" : "Account verification options", + "description" : "Method with which to verity the existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-email-verification", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "ALTERNATIVE", + "priority" : 20, + "flowAlias" : "Verify Existing Account by Re-authentication", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "75bd854e-ab99-46f1-90ed-a8bfc1559558", + "alias" : "Authentication Options", + "description" : "Authentication options.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "basic-auth", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "basic-auth-otp", + "requirement" : "DISABLED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "auth-spnego", + "requirement" : "DISABLED", + "priority" : 30, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "9b0e6cce-62c5-4fb6-a48d-e07c950e38c3", + "alias" : "Browser - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "auth-otp-form", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "1c26fd14-ac06-4dc1-bdd8-8c34c1b41720", + "alias" : "Direct Grant - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "direct-grant-validate-otp", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "254f7549-51ec-4565-a736-35c07b6e25f0", + "alias" : "First broker login - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "auth-otp-form", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "b2413da8-3de9-4bfe-b77e-643fd1964c8f", + "alias" : "Handle Existing Account", + "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-confirm-link", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "REQUIRED", + "priority" : 20, + "flowAlias" : "Account verification options", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "f8392bfb-8dce-4a16-8af1-b2a4d1a0a273", + "alias" : "Reset - Conditional OTP", + "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "reset-otp", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "fb69c297-b26e-44fa-aabd-d7b40eec3cd3", + "alias" : "User creation or linking", + "description" : "Flow for the existing/non-existing user alternatives", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "create unique user config", + "authenticator" : "idp-create-user-if-unique", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "ALTERNATIVE", + "priority" : 20, + "flowAlias" : "Handle Existing Account", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "de3a41a9-7018-4931-9c4d-d04f9501b2ce", + "alias" : "Verify Existing Account by Re-authentication", + "description" : "Reauthentication of existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-username-password-form", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 20, + "flowAlias" : "First broker login - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "6526b0d1-b48e-46c6-bb08-11ebcf458def", + "alias" : "browser", + "description" : "browser based authentication", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-cookie", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "auth-spnego", + "requirement" : "DISABLED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "identity-provider-redirector", + "requirement" : "ALTERNATIVE", + "priority" : 25, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "ALTERNATIVE", + "priority" : 30, + "flowAlias" : "forms", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "92a653ba-8f2d-4283-8354-ca55f9d89181", + "alias" : "clients", + "description" : "Base authentication for clients", + "providerId" : "client-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "client-secret", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "client-jwt", + "requirement" : "ALTERNATIVE", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "client-secret-jwt", + "requirement" : "ALTERNATIVE", + "priority" : 30, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "client-x509", + "requirement" : "ALTERNATIVE", + "priority" : 40, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "e365be39-78db-46f0-b2e8-4e7001c2f5d0", + "alias" : "direct grant", + "description" : "OpenID Connect Resource Owner Grant", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "direct-grant-validate-username", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "direct-grant-validate-password", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 30, + "flowAlias" : "Direct Grant - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "dd61caf5-a40f-48b7-9e8c-a1f3b67041dd", + "alias" : "docker auth", + "description" : "Used by Docker clients to authenticate against the IDP", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "docker-http-basic-authenticator", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "7a055643-62e1-4ac1-b126-9a8d6c299635", + "alias" : "first broker login", + "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "review profile config", + "authenticator" : "idp-review-profile", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "REQUIRED", + "priority" : 20, + "flowAlias" : "User creation or linking", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "fe8bc7ee-6e8f-436e-8336-c60fcd350843", + "alias" : "forms", + "description" : "Username, password, otp and other auth forms.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-username-password-form", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 20, + "flowAlias" : "Browser - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "3646f08e-ab70-415b-a701-6ed2e2d214c9", + "alias" : "http challenge", + "description" : "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "no-cookie-redirect", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "REQUIRED", + "priority" : 20, + "flowAlias" : "Authentication Options", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "04176530-0972-47ad-83df-19d8534caac2", + "alias" : "registration", + "description" : "registration flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-page-form", + "requirement" : "REQUIRED", + "priority" : 10, + "flowAlias" : "registration form", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "fa0ed569-6746-439e-b07e-89f7ed918c07", + "alias" : "registration form", + "description" : "registration form", + "providerId" : "form-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-user-creation", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "registration-profile-action", + "requirement" : "REQUIRED", + "priority" : 40, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "registration-password-action", + "requirement" : "REQUIRED", + "priority" : 50, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "registration-recaptcha-action", + "requirement" : "DISABLED", + "priority" : 60, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + }, { + "id" : "03680917-28f3-4ccd-bdf6-4a516f7c0018", + "alias" : "reset credentials", + "description" : "Reset credentials for a user if they forgot their password or something", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "reset-credentials-choose-user", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "reset-credential-email", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "reset-password", + "requirement" : "REQUIRED", + "priority" : 30, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 40, + "flowAlias" : "Reset - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] + }, { + "id" : "19a9d9aa-2d2b-4701-807f-c384ab921c7e", + "alias" : "saml ecp", + "description" : "SAML ECP Profile Authentication Flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "http-basic-authenticator", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] + } ], + "authenticatorConfig" : [ { + "id" : "534f01f4-45b3-43a0-91d1-238860cc126d", + "alias" : "create unique user config", + "config" : { + "require.password.update.after.registration" : "false" + } + }, { + "id" : "65bb9337-9633-4a21-8f6f-1d4129f664ac", + "alias" : "review profile config", + "config" : { + "update.profile.on.first.login" : "missing" + } + } ], + "requiredActions" : [ { + "alias" : "CONFIGURE_TOTP", + "name" : "Configure OTP", + "providerId" : "CONFIGURE_TOTP", + "enabled" : true, + "defaultAction" : false, + "priority" : 10, + "config" : { } + }, { + "alias" : "terms_and_conditions", + "name" : "Terms and Conditions", + "providerId" : "terms_and_conditions", + "enabled" : false, + "defaultAction" : false, + "priority" : 20, + "config" : { } + }, { + "alias" : "UPDATE_PASSWORD", + "name" : "Update Password", + "providerId" : "UPDATE_PASSWORD", + "enabled" : true, + "defaultAction" : false, + "priority" : 30, + "config" : { } + }, { + "alias" : "UPDATE_PROFILE", + "name" : "Update Profile", + "providerId" : "UPDATE_PROFILE", + "enabled" : true, + "defaultAction" : false, + "priority" : 40, + "config" : { } + }, { + "alias" : "VERIFY_EMAIL", + "name" : "Verify Email", + "providerId" : "VERIFY_EMAIL", + "enabled" : true, + "defaultAction" : false, + "priority" : 50, + "config" : { } + }, { + "alias" : "update_user_locale", + "name" : "Update User Locale", + "providerId" : "update_user_locale", + "enabled" : true, + "defaultAction" : false, + "priority" : 1000, + "config" : { } + } ], + "browserFlow" : "browser", + "registrationFlow" : "registration", + "directGrantFlow" : "direct grant", + "resetCredentialsFlow" : "reset credentials", + "clientAuthenticationFlow" : "clients", + "dockerAuthenticationFlow" : "docker auth", + "attributes" : { }, + "keycloakVersion" : "10.0.0", + "userManagedAccessAllowed" : false +} + diff --git a/deploy/oauth/keycloak/master-users-0.json b/deploy/oauth/keycloak/master-users-0.json new file mode 100644 index 000000000..69415e7f0 --- /dev/null +++ b/deploy/oauth/keycloak/master-users-0.json @@ -0,0 +1,31 @@ +{ + "realm": "master", + "users": [ + { + "id": "admin@datavisyn.io", + "createdTimestamp": 1591297959169, + "username": "admin", + "email": "admin@datavisyn.io", + "enabled": true, + "totp": false, + "emailVerified": true, + "credentials": [ + { + "id": "a1a06ecd-fdc0-4e67-92cd-2da22d724e32", + "type": "password", + "createdDate": 1591297959315, + "secretData": "{\"value\":\"6rt5zuqHVHopvd0FTFE0CYadXTtzY0mDY2BrqnNQGS51/7DfMJeGgj0roNnGMGvDv30imErNmiSOYl+cL9jiIA==\",\"salt\":\"LI0kqr09JB7J9wvr2Hxzzg==\"}", + "credentialData": "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["offline_access", "admin", "uma_authorization"], + "clientRoles": { + "account": ["view-profile", "manage-account"] + }, + "notBefore": 0, + "groups": [] + } + ] +} diff --git a/deploy/oauth/oauth2-proxy.cfg b/deploy/oauth/oauth2-proxy.cfg new file mode 100644 index 000000000..731e022a6 --- /dev/null +++ b/deploy/oauth/oauth2-proxy.cfg @@ -0,0 +1,25 @@ +# modified variant of oauth2-proxy-keycloak.cfg +# https://github.com/oauth2-proxy/oauth2-proxy/blob/master/contrib/local-environment/oauth2-proxy-keycloak.cfg +http_address="0.0.0.0:4180" +cookie_secret="OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" +email_domains=["*"] +cookie_secure="false" +upstreams="http://localhost:8080" +redirect_url="http://localhost:4180/oauth2/callback" + +# keycloak provider +client_secret="72341b6d-7065-4518-a0e4-50ee15025608" +client_id="oauth2-proxy" +whitelist_domains=["keycloak:9080"] + +oidc_issuer_url="http://keycloak:9080/auth/realms/master" +provider="oidc" +provider_display_name="Keycloak" + +# custom config, other than default +# https://github.com/oauth2-proxy/oauth2-proxy/issues/843#issuecomment-717212451 +# Header: Authorization: Bearer {jwt} +pass_authorization_header=true +# Header: X-Forwarded-Access-Token +pass_access_token=true +skip_provider_button = true From 5691649ee93d77d5b14521f5ebe7febb548f6a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=BChringer?= <51900829+puehringer@users.noreply.github.com> Date: Tue, 26 Sep 2023 15:46:20 +0800 Subject: [PATCH 6/8] feat: add context to visyn view components (#79) Co-authored-by: Holger Stitz --- src/views/visyn/demo/VisynDemoView.tsx | 5 +++++ src/views/visyn/interfaces.ts | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/views/visyn/demo/VisynDemoView.tsx b/src/views/visyn/demo/VisynDemoView.tsx index 11011cfda..ffe7cbe24 100644 --- a/src/views/visyn/demo/VisynDemoView.tsx +++ b/src/views/visyn/demo/VisynDemoView.tsx @@ -150,6 +150,10 @@ export function VisynDemoViewHeader({ parameters, selection, onParametersChanged ); } +export function VisynDemoViewContext({ children }: DemoVisynViewPluginType['props']) { + return children; +} + export function createVisynDemoView(): DemoVisynViewPluginType['definition'] { return { viewType: 'simple', @@ -161,5 +165,6 @@ export function createVisynDemoView(): DemoVisynViewPluginType['definition'] { view: VisynDemoView, header: VisynDemoViewHeader, tab: VisynDemoViewSidebar, + context: VisynDemoViewContext, }; } diff --git a/src/views/visyn/interfaces.ts b/src/views/visyn/interfaces.ts index 48d8dd713..1df6a4955 100644 --- a/src/views/visyn/interfaces.ts +++ b/src/views/visyn/interfaces.ts @@ -27,6 +27,10 @@ type VisynViewProps> = { * @param parameters New parameters. */ onParametersChanged(parameters: React.SetStateAction): void; + /** + * Children of this visyn view. This is used to pass the actual view component to the context component. + */ + children?: React.ReactNode; }; /** @@ -45,6 +49,10 @@ type VisynViewComponents = { * Optional side-tab component of this visyn view plugin. */ tab?: React.LazyExoticComponent> | React.ComponentType; + /** + * Optional context component of this visyn view plugin. This component wraps all of the above and allows to provide context values (i.e. via React.createContext). + */ + context?: React.LazyExoticComponent> | React.ComponentType; }; type BaseVisynViewDesc> = IBaseViewPluginDesc & { @@ -117,7 +125,7 @@ export interface DefineVisynViewPlugin< /** * Definition to be used as return value of the loader function of the module. */ - definition: Pick, 'viewType' | 'defaultParameters' | 'header' | 'view' | 'tab'>; + definition: Pick, 'viewType' | 'defaultParameters' | 'header' | 'view' | 'tab' | 'context'>; /** * Full plugin representing the loaded visyn view. */ From 50d6645ff9e50a9daa8c5113b774006b0bef5463 Mon Sep 17 00:00:00 2001 From: Holger Stitz Date: Wed, 27 Sep 2023 13:40:20 +0200 Subject: [PATCH 7/8] deps: update to Cypress 13.2 (#93) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 038b62801..ac36b835e 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,7 @@ "@storybook/react": "^7.0.12", "@storybook/react-webpack5": "^7.0.12", "@storybook/testing-library": "0.0.13", - "cypress": "^11.2.0", + "cypress": "^13.2.0", "storybook": "^7.0.12", "storybook-addon-swc": "^1.1.9" }, From 5d8beaa5ca66952ae8159c908ebdbf993423a076 Mon Sep 17 00:00:00 2001 From: Moritz Heckmann <77104411+dvmoritzschoefl@users.noreply.github.com> Date: Wed, 27 Sep 2023 14:36:40 +0200 Subject: [PATCH 8/8] feat: add new plots (heatmap, correlation matrix, raincloud) and refactor general vis (#76) Co-authored-by: dvzacharycutler Co-authored-by: oltionchampari Co-authored-by: Moritz Heckmann Co-authored-by: Daniela Co-authored-by: tschachinger Co-authored-by: dvmartinweigl Co-authored-by: Usama Ansari Co-authored-by: Holger Stitz Co-authored-by: Michael Puehringer Co-authored-by: Christian Bors --- .storybook/main.js | 45 +- .vscode/launch.json | 9 + package.json | 8 +- src/app/VisynAppProvider.tsx | 21 +- src/assets/icons/FilterClear.tsx | 20 + src/assets/icons/FilterEmpty.tsx | 13 + src/assets/icons/FilterFilled.tsx | 12 + src/components/HelpHoverCard.tsx | 20 + src/demo/MainApp.tsx | 20 +- src/locales/en/visyn.json | 1 + src/views/visyn/demo/VisynDemoView.tsx | 4 +- src/views/visyn/demo/interfaces.ts | 4 +- src/vis/EagerVis.tsx | 372 ++++++++++ src/vis/LazyVis.tsx | 4 +- src/vis/Provider.tsx | 89 +++ src/vis/Vis.tsx | 289 -------- src/vis/VisSidebar.tsx | 83 +-- src/vis/VisSidebarOpenButton.tsx | 11 +- src/vis/VisSidebarWrapper.tsx | 24 +- src/vis/bar/BarChart.tsx | 131 ++++ src/vis/bar/BarDirectionButtons.tsx | 25 + .../BarDisplayTypeButtons.tsx | 10 +- .../{sidebar => bar}/BarGroupTypeButtons.tsx | 2 +- src/vis/bar/BarVis.tsx | 307 +------- src/vis/bar/BarVisSidebar.tsx | 196 ++---- src/vis/{sidebar => bar}/GroupSelect.tsx | 19 +- src/vis/bar/SingleBarChart.tsx | 279 ++++++++ src/vis/bar/barComponents/Legend.tsx | 59 ++ src/vis/bar/barComponents/SingleBar.tsx | 68 ++ src/vis/bar/barComponents/XAxis.tsx | 78 +++ src/vis/bar/barComponents/YAxis.tsx | 91 +++ src/vis/bar/barTypes/GroupedBars.tsx | 93 +++ src/vis/bar/barTypes/SimpleBars.tsx | 81 +++ src/vis/bar/barTypes/StackedBars.tsx | 108 +++ src/vis/bar/hooks/useGetBarScales.ts | 95 +++ src/vis/bar/hooks/useGetGroupedBarScales.ts | 151 ++++ src/vis/bar/index.ts | 2 - src/vis/bar/interfaces.ts | 53 ++ src/vis/bar/utils.ts | 653 +++++------------- src/vis/correlation/CorrelationMatrix.tsx | 190 +++++ src/vis/correlation/CorrelationVis.tsx | 13 + src/vis/correlation/CorrelationVisSidebar.tsx | 112 +++ .../components/CorrelationPair.tsx | 118 ++++ src/vis/correlation/index.ts | 2 + src/vis/correlation/interfaces.ts | 18 + src/vis/correlation/utils.ts | 32 + src/vis/general/InvalidCols.tsx | 4 +- src/vis/heatmap/AnimatedLine.tsx | 42 ++ src/vis/heatmap/AnimatedText.tsx | 46 ++ src/vis/heatmap/Heatmap.tsx | 321 +++++++++ src/vis/heatmap/HeatmapGrid.tsx | 55 ++ src/vis/heatmap/HeatmapRect.tsx | 101 +++ src/vis/heatmap/HeatmapText.tsx | 108 +++ src/vis/heatmap/HeatmapVis.tsx | 22 + src/vis/heatmap/HeatmapVisSidebar.tsx | 54 ++ src/vis/heatmap/interfaces.ts | 24 + src/vis/heatmap/utils.ts | 52 ++ .../{sidebar => hexbin}/HexOpacitySwitch.tsx | 0 src/vis/hexbin/HexSizeSlider.tsx | 37 + src/vis/{sidebar => hexbin}/HexSizeSwitch.tsx | 0 .../HexbinOptionSelect.tsx | 2 +- src/vis/hexbin/HexbinVis.tsx | 269 +++++--- src/vis/hexbin/HexbinVisSidebar.tsx | 92 +-- src/vis/hexbin/Hexplot.tsx | 151 +--- src/vis/hexbin/SingleHex.tsx | 3 +- src/vis/hexbin/XAxis.tsx | 10 +- src/vis/hexbin/interfaces.ts | 22 + src/vis/hexbin/utils.tsx | 32 +- src/vis/index.ts | 14 +- src/vis/interfaces.ts | 123 ++-- src/vis/legend/ColorLegend.tsx | 80 +++ src/vis/legend/ColorLegendVert.tsx | 79 +++ src/vis/raincloud/AggregateRainSwitch.tsx | 11 + src/vis/raincloud/Brush.tsx | 39 ++ src/vis/raincloud/Raincloud.tsx | 193 ++++++ src/vis/raincloud/RaincloudCloudSelect.tsx | 17 + src/vis/raincloud/RaincloudGrid.tsx | 35 + .../raincloud/RaincloudLightningSelect.tsx | 18 + src/vis/raincloud/RaincloudRainSelect.tsx | 18 + src/vis/raincloud/RaincloudVis.tsx | 19 + src/vis/raincloud/RaincloudVisSidebar.tsx | 32 + src/vis/raincloud/cloud/Heatmap.tsx | 76 ++ src/vis/raincloud/cloud/Histogram.tsx | 67 ++ src/vis/raincloud/cloud/SplitViolin.tsx | 58 ++ src/vis/raincloud/cloud/useKdeCalc.tsx | 64 ++ src/vis/raincloud/hooks/useXScale.tsx | 35 + src/vis/raincloud/interfaces.ts | 40 ++ src/vis/raincloud/lightning/Boxplot.tsx | 69 ++ src/vis/raincloud/lightning/Mean.tsx | 52 ++ .../raincloud/lightning/MeanAndInterval.tsx | 54 ++ .../raincloud/lightning/MedianAndInterval.tsx | 56 ++ src/vis/raincloud/rain/BeeSwarm.tsx | 80 +++ src/vis/raincloud/rain/Circle.tsx | 57 ++ src/vis/raincloud/rain/DotPlot.tsx | 65 ++ src/vis/raincloud/rain/StripPlot.tsx | 47 ++ src/vis/raincloud/rain/WheatPlot.tsx | 70 ++ src/vis/raincloud/utils.ts | 34 + src/vis/sankey/SankeyVis.tsx | 234 +++++++ src/vis/sankey/SankeyVisSidebar.tsx | 20 + src/vis/sankey/interfaces.ts | 10 + src/vis/sankey/utils.ts | 13 + src/vis/{sidebar => scatter}/ColorSelect.tsx | 8 +- src/vis/scatter/OpacitySlider.tsx | 38 + src/vis/scatter/ScatterVis.tsx | 214 ++---- src/vis/scatter/ScatterVisSidebar.tsx | 157 ++--- src/vis/scatter/interfaces.ts | 16 + src/vis/scatter/utils.ts | 49 +- src/vis/sidebar/AggregateTypeSelect.tsx | 1 + src/vis/sidebar/BarDirectionButtons.tsx | 28 - src/vis/sidebar/CategoricalColumnSelect.tsx | 2 +- src/vis/sidebar/CloseButton.tsx | 9 - src/vis/sidebar/FilterButtons.tsx | 11 +- src/vis/sidebar/HexSizeSlider.tsx | 42 -- src/vis/sidebar/NumericalColorButtons.tsx | 58 +- src/vis/sidebar/NumericalColumnSelect.tsx | 3 +- src/vis/sidebar/OpacitySlider.tsx | 43 -- src/vis/sidebar/SingleColumnSelect.tsx | 7 +- src/vis/sidebar/ViolinOverlayButtons.tsx | 28 - src/vis/sidebar/VisTypeSelect.tsx | 53 +- src/vis/sidebar/index.ts | 14 +- src/vis/stories/Iris.stories.tsx | 47 +- src/vis/stories/Vis/Bar/BarDocs.stories.mdx | 25 + src/vis/stories/Vis/Bar/BarRandom.stories.tsx | 369 ++++++++++ .../Correlation/CorrelationIris.stories.tsx | 69 ++ .../Vis/Heatmap/HeatmapRandom.stories.tsx | 190 +++++ .../stories/Vis/Hexbin/HexbinDocs.stories.mdx | 24 + .../Vis/Hexbin/HexbinRandom.stories.tsx | 248 +++++++ .../Vis/Raincloud/RaincloudDocs.stories.mdx | 22 + .../Vis/Raincloud/RaincloudRandom.stories.tsx | 336 +++++++++ .../Vis/Scatter/ScatterDocs.stories.mdx | 23 + .../Vis/Scatter/ScatterIris.stories.tsx | 138 ++++ .../Scatter/ScatterRandom.stories.tsx} | 100 +-- .../stories/Vis/Violin/ViolinDocs.stories.mdx | 19 + .../stories/Vis/Violin/ViolinIris.stories.tsx | 82 +++ src/vis/stories/fetchIrisData.tsx | 52 ++ src/vis/stories/irisData.ts | 300 ++++---- src/vis/violin/ViolinOverlayButtons.tsx | 25 + src/vis/violin/ViolinVis.tsx | 162 +---- src/vis/violin/ViolinVisSidebar.tsx | 80 +-- src/vis/violin/interfaces.ts | 17 + src/vis/violin/utils.ts | 23 +- tsconfig.json | 1 + 142 files changed, 7839 insertions(+), 2655 deletions(-) create mode 100644 src/assets/icons/FilterClear.tsx create mode 100644 src/assets/icons/FilterEmpty.tsx create mode 100644 src/assets/icons/FilterFilled.tsx create mode 100644 src/components/HelpHoverCard.tsx create mode 100644 src/vis/EagerVis.tsx create mode 100644 src/vis/Provider.tsx delete mode 100644 src/vis/Vis.tsx create mode 100644 src/vis/bar/BarChart.tsx create mode 100644 src/vis/bar/BarDirectionButtons.tsx rename src/vis/{sidebar => bar}/BarDisplayTypeButtons.tsx (61%) rename src/vis/{sidebar => bar}/BarGroupTypeButtons.tsx (93%) rename src/vis/{sidebar => bar}/GroupSelect.tsx (67%) create mode 100644 src/vis/bar/SingleBarChart.tsx create mode 100644 src/vis/bar/barComponents/Legend.tsx create mode 100644 src/vis/bar/barComponents/SingleBar.tsx create mode 100644 src/vis/bar/barComponents/XAxis.tsx create mode 100644 src/vis/bar/barComponents/YAxis.tsx create mode 100644 src/vis/bar/barTypes/GroupedBars.tsx create mode 100644 src/vis/bar/barTypes/SimpleBars.tsx create mode 100644 src/vis/bar/barTypes/StackedBars.tsx create mode 100644 src/vis/bar/hooks/useGetBarScales.ts create mode 100644 src/vis/bar/hooks/useGetGroupedBarScales.ts delete mode 100644 src/vis/bar/index.ts create mode 100644 src/vis/bar/interfaces.ts create mode 100644 src/vis/correlation/CorrelationMatrix.tsx create mode 100644 src/vis/correlation/CorrelationVis.tsx create mode 100644 src/vis/correlation/CorrelationVisSidebar.tsx create mode 100644 src/vis/correlation/components/CorrelationPair.tsx create mode 100644 src/vis/correlation/index.ts create mode 100644 src/vis/correlation/interfaces.ts create mode 100644 src/vis/correlation/utils.ts create mode 100644 src/vis/heatmap/AnimatedLine.tsx create mode 100644 src/vis/heatmap/AnimatedText.tsx create mode 100644 src/vis/heatmap/Heatmap.tsx create mode 100644 src/vis/heatmap/HeatmapGrid.tsx create mode 100644 src/vis/heatmap/HeatmapRect.tsx create mode 100644 src/vis/heatmap/HeatmapText.tsx create mode 100644 src/vis/heatmap/HeatmapVis.tsx create mode 100644 src/vis/heatmap/HeatmapVisSidebar.tsx create mode 100644 src/vis/heatmap/interfaces.ts create mode 100644 src/vis/heatmap/utils.ts rename src/vis/{sidebar => hexbin}/HexOpacitySwitch.tsx (100%) create mode 100644 src/vis/hexbin/HexSizeSlider.tsx rename src/vis/{sidebar => hexbin}/HexSizeSwitch.tsx (100%) rename src/vis/{sidebar => hexbin}/HexbinOptionSelect.tsx (93%) create mode 100644 src/vis/hexbin/interfaces.ts create mode 100644 src/vis/legend/ColorLegend.tsx create mode 100644 src/vis/legend/ColorLegendVert.tsx create mode 100644 src/vis/raincloud/AggregateRainSwitch.tsx create mode 100644 src/vis/raincloud/Brush.tsx create mode 100644 src/vis/raincloud/Raincloud.tsx create mode 100644 src/vis/raincloud/RaincloudCloudSelect.tsx create mode 100644 src/vis/raincloud/RaincloudGrid.tsx create mode 100644 src/vis/raincloud/RaincloudLightningSelect.tsx create mode 100644 src/vis/raincloud/RaincloudRainSelect.tsx create mode 100644 src/vis/raincloud/RaincloudVis.tsx create mode 100644 src/vis/raincloud/RaincloudVisSidebar.tsx create mode 100644 src/vis/raincloud/cloud/Heatmap.tsx create mode 100644 src/vis/raincloud/cloud/Histogram.tsx create mode 100644 src/vis/raincloud/cloud/SplitViolin.tsx create mode 100644 src/vis/raincloud/cloud/useKdeCalc.tsx create mode 100644 src/vis/raincloud/hooks/useXScale.tsx create mode 100644 src/vis/raincloud/interfaces.ts create mode 100644 src/vis/raincloud/lightning/Boxplot.tsx create mode 100644 src/vis/raincloud/lightning/Mean.tsx create mode 100644 src/vis/raincloud/lightning/MeanAndInterval.tsx create mode 100644 src/vis/raincloud/lightning/MedianAndInterval.tsx create mode 100644 src/vis/raincloud/rain/BeeSwarm.tsx create mode 100644 src/vis/raincloud/rain/Circle.tsx create mode 100644 src/vis/raincloud/rain/DotPlot.tsx create mode 100644 src/vis/raincloud/rain/StripPlot.tsx create mode 100644 src/vis/raincloud/rain/WheatPlot.tsx create mode 100644 src/vis/raincloud/utils.ts create mode 100644 src/vis/sankey/SankeyVis.tsx create mode 100644 src/vis/sankey/SankeyVisSidebar.tsx create mode 100644 src/vis/sankey/interfaces.ts create mode 100644 src/vis/sankey/utils.ts rename src/vis/{sidebar => scatter}/ColorSelect.tsx (84%) create mode 100644 src/vis/scatter/OpacitySlider.tsx create mode 100644 src/vis/scatter/interfaces.ts delete mode 100644 src/vis/sidebar/BarDirectionButtons.tsx delete mode 100644 src/vis/sidebar/CloseButton.tsx delete mode 100644 src/vis/sidebar/HexSizeSlider.tsx delete mode 100644 src/vis/sidebar/OpacitySlider.tsx delete mode 100644 src/vis/sidebar/ViolinOverlayButtons.tsx create mode 100644 src/vis/stories/Vis/Bar/BarDocs.stories.mdx create mode 100644 src/vis/stories/Vis/Bar/BarRandom.stories.tsx create mode 100644 src/vis/stories/Vis/Correlation/CorrelationIris.stories.tsx create mode 100644 src/vis/stories/Vis/Heatmap/HeatmapRandom.stories.tsx create mode 100644 src/vis/stories/Vis/Hexbin/HexbinDocs.stories.mdx create mode 100644 src/vis/stories/Vis/Hexbin/HexbinRandom.stories.tsx create mode 100644 src/vis/stories/Vis/Raincloud/RaincloudDocs.stories.mdx create mode 100644 src/vis/stories/Vis/Raincloud/RaincloudRandom.stories.tsx create mode 100644 src/vis/stories/Vis/Scatter/ScatterDocs.stories.mdx create mode 100644 src/vis/stories/Vis/Scatter/ScatterIris.stories.tsx rename src/vis/stories/{Random.stories.tsx => Vis/Scatter/ScatterRandom.stories.tsx} (64%) create mode 100644 src/vis/stories/Vis/Violin/ViolinDocs.stories.mdx create mode 100644 src/vis/stories/Vis/Violin/ViolinIris.stories.tsx create mode 100644 src/vis/stories/fetchIrisData.tsx create mode 100644 src/vis/violin/ViolinOverlayButtons.tsx create mode 100644 src/vis/violin/interfaces.ts diff --git a/.storybook/main.js b/.storybook/main.js index 592e9be82..a2340ac17 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,30 +1,41 @@ const path = require('path'); module.exports = { stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/preset-scss', 'storybook-addon-swc', '@storybook/addon-mdx-gfm'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/preset-scss', + 'storybook-addon-swc', + '@storybook/addon-mdx-gfm', + ], framework: { name: '@storybook/react-webpack5', - options: {} + options: {}, }, - webpackFinal: async config => { + webpackFinal: async (config) => { + // TODO:: Setting minimize here because storybook fails on prod otherwise. Specifically, we get arquero errors along the lines of "Invalid variable reference: "a"". + // This same problem does not occur on our prod builds, only for this storybook webpack compiler + config.optimization.minimize = false; // This is required to enable TS moduleResolution: node16, as there we have to add .js extensions which are actually .ts files. - config.resolve.extensionAlias = { + (config.resolve.extensionAlias = { ...(config.resolve.extensionAlias || {}), '.js': ['.tsx', '.ts', '.js'], '.cjs': ['.cts', '.cjs'], - '.mjs': ['.mts', '.mjs'] - }, config.resolve.alias = { - ...(config.resolve.alias || {}), - // I have no clue why this is required, but if this is missing we get a "Can't resolve '../../assets/icons/datavisyn_logo.svg' in '.../src/scss'"" - '../../assets': path.resolve(__dirname, '../src/assets'), - // Add visyn_core/dist as alias, as we have scss/code imports like visyn_core/dist/assets/... - 'visyn_core/dist': path.resolve(__dirname, '../src'), - 'visyn_core/src': path.resolve(__dirname, '../src'), - 'visyn_core': path.resolve(__dirname, '../src') - }; + '.mjs': ['.mts', '.mjs'], + }), + (config.resolve.alias = { + ...(config.resolve.alias || {}), + // I have no clue why this is required, but if this is missing we get a "Can't resolve '../../assets/icons/datavisyn_logo.svg' in '.../src/scss'"" + '../../assets': path.resolve(__dirname, '../src/assets'), + // Add visyn_core/dist as alias, as we have scss/code imports like visyn_core/dist/assets/... + 'visyn_core/dist': path.resolve(__dirname, '../src'), + 'visyn_core/src': path.resolve(__dirname, '../src'), + visyn_core: path.resolve(__dirname, '../src'), + }); return config; }, docs: { - autodocs: true - } -}; \ No newline at end of file + autodocs: true, + }, +}; diff --git a/.vscode/launch.json b/.vscode/launch.json index acf771dca..ddaccc487 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,15 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + + { + "name": "Debug Storybook", + "request": "launch", + "type": "chrome", + "url": "http://localhost:6006", + "webRoot": "${workspaceFolder}" + }, + { "name": "Python: Module", "type": "python", diff --git a/package.json b/package.json index ac36b835e..d80da1bfb 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,8 @@ "storybook": "NODE_OPTIONS=--max_old_space_size=4096 storybook dev -p 6006", "test": "visyn_scripts test", "webpack:dev": "visyn_scripts build --mode development --env workspace_mode=single", - "webpack:prod": "visyn_scripts build --mode production --env workspace_mode=single" + "webpack:prod": "visyn_scripts build --mode production --env workspace_mode=single", + "chromatic": "npx chromatic --build-script-name storybook:build" }, "dependencies": { "@emotion/react": "^11.0.0", @@ -150,9 +151,12 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/react-plotly.js": "^2.5.0", + "arquero": "^5.2.0", + "d3-force-boundary": "^0.0.3", "d3-hexbin": "^0.2.2", "d3v7": "npm:d3@^7.4.0", "i18next": "^22.4.15", + "jstat": "^1.9.6", "lineupjs": "4.9.0", "lodash": "~4.17.20", "plotly.js-dist-min": "~2.12.0", @@ -160,6 +164,7 @@ "react-dom": "^18.2.0", "react-highlight-words": "^0.17.0", "react-plotly.js": "^2.5.1", + "react-spring": "^9.7.1", "use-deep-compare-effect": "^1.8.0", "visyn_scripts": "git+ssh://git@github.com/datavisyn/visyn_scripts#develop" }, @@ -179,6 +184,7 @@ "@storybook/react": "^7.0.12", "@storybook/react-webpack5": "^7.0.12", "@storybook/testing-library": "0.0.13", + "chromatic": "^6.19.9", "cypress": "^13.2.0", "storybook": "^7.0.12", "storybook-addon-swc": "^1.1.9" diff --git a/src/app/VisynAppProvider.tsx b/src/app/VisynAppProvider.tsx index d240d2c6e..800460a1b 100644 --- a/src/app/VisynAppProvider.tsx +++ b/src/app/VisynAppProvider.tsx @@ -1,10 +1,11 @@ -import * as React from 'react'; import { MantineProvider, MantineProviderProps } from '@mantine/core'; -import merge from 'lodash/merge'; -import { Notifications, NotificationsProps } from '@mantine/notifications'; import { ModalsProvider, ModalsProviderProps } from '@mantine/modals'; +import { Notifications, NotificationsProps } from '@mantine/notifications'; +import merge from 'lodash/merge'; +import * as React from 'react'; import { loadClientConfig } from '../base/clientConfig'; import { useAsync, useInitVisynApp, useVisynUser } from '../hooks'; +import { VisProvider } from '../vis/Provider'; import { VisynAppContext } from './VisynAppContext'; import { DEFAULT_MANTINE_PROVIDER_PROPS } from './constants'; @@ -55,11 +56,13 @@ export function VisynAppProvider({ const mergedMantineProviderProps = React.useMemo(() => merge(merge({}, DEFAULT_MANTINE_PROVIDER_PROPS), mantineProviderProps || {}), [mantineProviderProps]); return ( - - - - {initStatus === 'success' && successfulClientConfigInit ? children : null} - - + + + + + {initStatus === 'success' && successfulClientConfigInit ? children : null} + + + ); } diff --git a/src/assets/icons/FilterClear.tsx b/src/assets/icons/FilterClear.tsx new file mode 100644 index 000000000..6ccfc1507 --- /dev/null +++ b/src/assets/icons/FilterClear.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export function FilterClear({ width, height, color = '#C8CED3' }: { width: number; height: number; color?: string }) { + return ( + + + + + ); +} diff --git a/src/assets/icons/FilterEmpty.tsx b/src/assets/icons/FilterEmpty.tsx new file mode 100644 index 000000000..2efc26353 --- /dev/null +++ b/src/assets/icons/FilterEmpty.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export function FilterEmpty({ width, height, color = '#C8CED3' }: { width: number; height: number; color?: string }) { + return ( + + + + ); +} diff --git a/src/assets/icons/FilterFilled.tsx b/src/assets/icons/FilterFilled.tsx new file mode 100644 index 000000000..9566e264c --- /dev/null +++ b/src/assets/icons/FilterFilled.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export function FilterFilled({ width, height, color = '#C8CED3' }: { width: number; height: number; color?: string }) { + return ( + + + + ); +} diff --git a/src/components/HelpHoverCard.tsx b/src/components/HelpHoverCard.tsx new file mode 100644 index 000000000..7710dd78a --- /dev/null +++ b/src/components/HelpHoverCard.tsx @@ -0,0 +1,20 @@ +import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { ActionIcon, Group, HoverCard, Title } from '@mantine/core'; +import React from 'react'; + +export function HelpHoverCard({ title, content, dataCyPrefix }: { title: JSX.Element; content: JSX.Element; dataCyPrefix?: string }) { + return ( + + {title} + + + + + + + {content} + + + ); +} diff --git a/src/demo/MainApp.tsx b/src/demo/MainApp.tsx index 60ffc0416..4d3f9cfcf 100644 --- a/src/demo/MainApp.tsx +++ b/src/demo/MainApp.tsx @@ -1,16 +1,17 @@ -import * as React from 'react'; import { Loader, Select, SimpleGrid, Stack, Text } from '@mantine/core'; -import { Vis, ESupportedPlotlyVis, ENumericalColorScaleType, EScatterSelectSettings, IVisConfig } from '../vis'; -import { fetchIrisData } from '../vis/stories/Iris.stories'; -import { iris } from '../vis/stories/irisData'; -import { useVisynAppContext, VisynApp, VisynHeader } from '../app'; +import * as React from 'react'; +import { VisynApp, VisynHeader, useVisynAppContext } from '../app'; import { VisynRanking } from '../ranking'; import { IBuiltVisynRanking } from '../ranking/EagerVisynRanking'; +import { BaseVisConfig, ENumericalColorScaleType, EScatterSelectSettings, ESupportedPlotlyVis, Vis } from '../vis'; +import { IScatterConfig } from '../vis/scatter/interfaces'; +import { fetchIrisData } from '../vis/stories/Iris.stories'; +import { iris } from '../vis/stories/irisData'; import { MyNumberScore, MyStringScore } from './scoresUtils'; export function MainApp() { const { user } = useVisynAppContext(); - const [visConfig, setVisConfig] = React.useState({ + const [visConfig, setVisConfig] = React.useState({ type: ESupportedPlotlyVis.SCATTER, numColumnsSelected: [ { @@ -33,7 +34,8 @@ export function MainApp() { shape: null, dragMode: EScatterSelectSettings.RECTANGLE, alphaSliderVal: 1, - }); + sizeSliderVal: 5, + } as IScatterConfig); const columns = React.useMemo(() => (user ? fetchIrisData() : []), [user]); const [selection, setSelection] = React.useState([]); @@ -87,7 +89,9 @@ export function MainApp() { setExternalConfig={setVisConfig} selected={visSelection} selectionCallback={(s) => { - setSelection(s.map((i) => iris[+i])); + if (s) { + setSelection(s.map((i) => iris[+i])); + } }} /> diff --git a/src/locales/en/visyn.json b/src/locales/en/visyn.json index 812880f4c..d8950805b 100644 --- a/src/locales/en/visyn.json +++ b/src/locales/en/visyn.json @@ -9,6 +9,7 @@ "openSettings": "Open settings", "closeSettings": "Close settings", "hexbinOptions": "Hexbin options", + "animation": "Animation", "missingValue": "n/a" }, "permission": { diff --git a/src/views/visyn/demo/VisynDemoView.tsx b/src/views/visyn/demo/VisynDemoView.tsx index ffe7cbe24..470620ba8 100644 --- a/src/views/visyn/demo/VisynDemoView.tsx +++ b/src/views/visyn/demo/VisynDemoView.tsx @@ -104,8 +104,8 @@ export function VisynDemoViewSidebar({ parameters, onParametersChanged }: DemoVi alignSelf: 'center', }} columns={parameters.columns} - externalConfig={parameters.config} - setExternalConfig={(config) => { + config={parameters.config} + setConfig={(config) => { onParametersChanged((p) => ({ ...p, config, diff --git a/src/views/visyn/demo/interfaces.ts b/src/views/visyn/demo/interfaces.ts index 190a1a383..dbce3fb2f 100644 --- a/src/views/visyn/demo/interfaces.ts +++ b/src/views/visyn/demo/interfaces.ts @@ -1,8 +1,8 @@ +import type { BaseVisConfig, VisColumn } from '../../../vis/interfaces'; import { VisynSimpleViewPluginType } from '../interfaces'; -import type { VisColumn, IVisConfig } from '../../../vis/interfaces'; export type DemoVisynViewPluginType = VisynSimpleViewPluginType<{ columns: VisColumn[] | null; - config: IVisConfig | null; + config: BaseVisConfig | null; dataLength: number; }>; diff --git a/src/vis/EagerVis.tsx b/src/vis/EagerVis.tsx new file mode 100644 index 000000000..9518be019 --- /dev/null +++ b/src/vis/EagerVis.tsx @@ -0,0 +1,372 @@ +import { Group, Stack } from '@mantine/core'; +import { useResizeObserver, useUncontrolled } from '@mantine/hooks'; +import * as d3v7 from 'd3v7'; +import * as React from 'react'; +import { useEffect, useMemo } from 'react'; +import { useSyncedRef } from '../hooks/useSyncedRef'; +import { getCssValue } from '../utils'; +import { createVis, useVisProvider } from './Provider'; +import { VisSidebarWrapper } from './VisSidebarWrapper'; +import { + BaseVisConfig, + EAggregateTypes, + EColumnTypes, + EFilterOptions, + ENumericalColorScaleType, + EScatterSelectSettings, + ESupportedPlotlyVis, + Scales, + VisColumn, +} from './interfaces'; + +import { VisSidebar } from './VisSidebar'; +import { VisSidebarOpenButton } from './VisSidebarOpenButton'; +import { BarVis } from './bar/BarVis'; +import { BarVisSidebar } from './bar/BarVisSidebar'; +import { EBarDirection, EBarDisplayType, EBarGroupingType, IBarConfig } from './bar/interfaces'; +import { barMergeDefaultConfig } from './bar/utils'; +import { correlationMergeDefaultConfig } from './correlation'; +import { CorrelationVis } from './correlation/CorrelationVis'; +import { CorrelationVisSidebar } from './correlation/CorrelationVisSidebar'; +import { ICorrelationConfig } from './correlation/interfaces'; +import { HeatmapVis } from './heatmap/HeatmapVis'; +import { HeatmapVisSidebar } from './heatmap/HeatmapVisSidebar'; +import { IHeatmapConfig } from './heatmap/interfaces'; +import { heatmapMergeDefaultConfig } from './heatmap/utils'; +import { HexbinVis } from './hexbin/HexbinVis'; +import { HexbinVisSidebar } from './hexbin/HexbinVisSidebar'; +import { IHexbinConfig } from './hexbin/interfaces'; +import { hexinbMergeDefaultConfig } from './hexbin/utils'; +import { RaincloudVis } from './raincloud/RaincloudVis'; +import { RaincloudVisSidebar } from './raincloud/RaincloudVisSidebar'; +import { IRaincloudConfig } from './raincloud/interfaces'; +import { raincloudMergeDefaultConfig } from './raincloud/utils'; +import { SankeyVis } from './sankey/SankeyVis'; +import { SankeyVisSidebar } from './sankey/SankeyVisSidebar'; +import { ISankeyConfig } from './sankey/interfaces'; +import { sankeyMergeDefaultConfig } from './sankey/utils'; +import { scatterMergeDefaultConfig } from './scatter'; +import { ScatterVis } from './scatter/ScatterVis'; +import { ScatterVisSidebar } from './scatter/ScatterVisSidebar'; +import { IScatterConfig } from './scatter/interfaces'; +import { ViolinVis, violinMergeDefaultConfig } from './violin'; +import { ViolinVisSidebar } from './violin/ViolinVisSidebar'; +import { IViolinConfig } from './violin/interfaces'; + +const DEFAULT_SHAPES = ['circle', 'square', 'triangle-up', 'star']; + +function registerAllVis() { + return [ + createVis({ + type: ESupportedPlotlyVis.SCATTER, + renderer: ScatterVis, + sidebarRenderer: ScatterVisSidebar, + mergeConfig: scatterMergeDefaultConfig, + description: 'Visualizes two variables as individual data points in two-dimensional space', + }), + createVis({ + type: ESupportedPlotlyVis.BAR, + renderer: BarVis, + sidebarRenderer: BarVisSidebar, + mergeConfig: barMergeDefaultConfig, + description: 'Visualizes categorical data with rectangular bars', + }), + createVis({ + type: ESupportedPlotlyVis.HEXBIN, + renderer: HexbinVis, + sidebarRenderer: HexbinVisSidebar, + mergeConfig: hexinbMergeDefaultConfig, + description: 'Visualizes 2D data points within hexagons', + }), + createVis({ + type: ESupportedPlotlyVis.SANKEY, + renderer: SankeyVis, + sidebarRenderer: SankeyVisSidebar, + mergeConfig: sankeyMergeDefaultConfig, + description: 'Visualizes the flow of data between different categories', + }), + createVis({ + type: ESupportedPlotlyVis.HEATMAP, + renderer: HeatmapVis, + sidebarRenderer: HeatmapVisSidebar, + mergeConfig: heatmapMergeDefaultConfig, + description: 'Visualizes matrix data using color gradients', + }), + createVis({ + type: ESupportedPlotlyVis.VIOLIN, + renderer: ViolinVis, + sidebarRenderer: ViolinVisSidebar, + mergeConfig: violinMergeDefaultConfig, + description: 'Visualizes numerical data distribution by combining a box plot and a kernel density plot', + }), + createVis({ + type: ESupportedPlotlyVis.RAINCLOUD, + renderer: RaincloudVis, + sidebarRenderer: RaincloudVisSidebar, + mergeConfig: raincloudMergeDefaultConfig, + description: 'Visualizes a combination of boxplot, violin plot, and jitter plot', + }), + createVis({ + type: ESupportedPlotlyVis.CORRELATION, + renderer: CorrelationVis, + sidebarRenderer: CorrelationVisSidebar, + mergeConfig: correlationMergeDefaultConfig, + description: 'Visualizes statistical relationships between pairs of numerical variables', + }), + ]; +} + +export function useRegisterDefaultVis() { + const { registerVisType } = useVisProvider(); + + React.useEffect(() => { + registerVisType(...registerAllVis()); + }, [registerVisType]); +} + +export function EagerVis({ + columns, + selected = [], + colors = null, + shapes = DEFAULT_SHAPES, + selectionCallback = () => null, + filterCallback = () => null, + setExternalConfig = () => null, + closeCallback = () => null, + showCloseButton = false, + externalConfig = null, + enableSidebar = true, + showSidebar: internalShowSidebar, + showDragModeOptions = true, + setShowSidebar: internalSetShowSidebar, + showSidebarDefault = false, + scrollZoom = true, +}: { + /** + * Required data columns which are displayed. + */ + columns: VisColumn[]; + /** + * Optional Prop for identifying which points are selected. Any ids that are in this array will be considered selected. + */ + selected?: string[]; + /** + * Optional Prop for changing the colors that are used in color mapping. Defaults to the Datavisyn categorical color scheme + */ + colors?: string[]; + /** + * Optional Prop for changing the shapes that are used in shape mapping. Defaults to the circle, square, triangle, star. + */ + shapes?: string[]; + /** + * Optional Prop which is called when a selection is made in the scatterplot visualization. Passes in the selected points. + */ + selectionCallback?: (s: string[]) => void; + /** + * Optional Prop which is called when a filter is applied. Returns a string identifying what type of filter is desired. This logic will be simplified in the future. + */ + filterCallback?: (s: EFilterOptions) => void; + setExternalConfig?: (config: BaseVisConfig) => void; + closeCallback?: () => void; + showCloseButton?: boolean; + externalConfig?: + | IScatterConfig + | IBarConfig + | IHexbinConfig + | ISankeyConfig + | IHeatmapConfig + | IViolinConfig + | IRaincloudConfig + | ICorrelationConfig + | BaseVisConfig; + enableSidebar?: boolean; + showSidebar?: boolean; + showDragModeOptions?: boolean; + setShowSidebar?(show: boolean): void; + showSidebarDefault?: boolean; + scrollZoom?: boolean; +}) { + const [showSidebar, setShowSidebar] = useUncontrolled({ + value: internalShowSidebar, + defaultValue: showSidebarDefault, + finalValue: false, + onChange: internalSetShowSidebar, + }); + + const [ref, dimensions] = useResizeObserver(); + + useRegisterDefaultVis(); + + const { getVisByType } = useVisProvider(); + + // Each time you switch between vis config types, there is one render where the config is inconsistent with the type before the merge functions in the useEffect below can be called. + // To ensure that we never render an incosistent config, keep a consistent and a current in the config. Always render the consistent. + // eslint-disable-next-line @typescript-eslint/naming-convention + const [{ consistent: visConfig, current: inconsistentVisConfig }, _setVisConfig] = React.useState<{ + consistent: BaseVisConfig; + current: BaseVisConfig; + }>( + externalConfig + ? { consistent: null, current: externalConfig } + : columns.filter((c) => c.type === EColumnTypes.NUMERICAL).length > 1 + ? { + consistent: null, + current: { + type: ESupportedPlotlyVis.SCATTER, + numColumnsSelected: [], + color: null, + numColorScaleType: ENumericalColorScaleType.SEQUENTIAL, + shape: null, + dragMode: EScatterSelectSettings.RECTANGLE, + alphaSliderVal: 0.5, + } as BaseVisConfig, + } + : { + consistent: null, + current: { + type: ESupportedPlotlyVis.BAR, + multiples: null, + group: null, + direction: EBarDirection.HORIZONTAL, + display: EBarDisplayType.ABSOLUTE, + groupType: EBarGroupingType.STACK, + numColumnsSelected: [], + catColumnSelected: null, + aggregateColumn: null, + aggregateType: EAggregateTypes.COUNT, + } as BaseVisConfig, + }, + ); + + const setExternalConfigRef = useSyncedRef(setExternalConfig); + useEffect(() => { + setExternalConfigRef.current?.(visConfig); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(visConfig), setExternalConfigRef]); + + const setVisConfig = React.useCallback((newConfig: BaseVisConfig) => { + _setVisConfig((oldConfig) => { + return { + current: newConfig, + consistent: oldConfig.current.type !== newConfig.type ? oldConfig.consistent : newConfig, + }; + }); + }, []); + + React.useEffect(() => { + const vis = getVisByType(inconsistentVisConfig?.type); + if (vis) { + const newConfig = vis.mergeConfig(columns, inconsistentVisConfig); + _setVisConfig({ current: newConfig, consistent: newConfig }); + } + + // DANGER:: this useEffect should only occur when the visConfig.type changes. adding visconfig into the dep array will cause an infinite loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inconsistentVisConfig?.type, getVisByType]); + + useEffect(() => { + if (externalConfig) { + setVisConfig(externalConfig); + } + }, [externalConfig, setVisConfig]); + + // Converting the selected list into a map, since searching through the list to find an item is common in the vis components. + const selectedMap: { [key: string]: boolean } = useMemo(() => { + const currMap: { [key: string]: boolean } = {}; + + selected.forEach((s) => { + currMap[s] = true; + }); + + return currMap; + }, [selected]); + + const scales: Scales = useMemo(() => { + const colorScale = d3v7 + .scaleOrdinal() + .range( + colors || [ + getCssValue('visyn-c1'), + getCssValue('visyn-c2'), + getCssValue('visyn-c3'), + getCssValue('visyn-c4'), + getCssValue('visyn-c5'), + getCssValue('visyn-c6'), + getCssValue('visyn-c7'), + getCssValue('visyn-c8'), + getCssValue('visyn-c9'), + getCssValue('visyn-c10'), + ], + ); + + return { + color: colorScale, + }; + }, [colors]); + + if (!visConfig) { + return
; + } + + const commonProps = { + showSidebar, + setShowSidebar, + enableSidebar, + }; + + const Renderer = getVisByType(visConfig?.type)?.renderer; + + return ( + + {enableSidebar && !showSidebar ? setShowSidebar(!showSidebar)} /> : null} + + + {Renderer ? ( + + ) : null} + + {showSidebar ? ( + setShowSidebar(false)}> + + + ) : null} + + ); +} diff --git a/src/vis/LazyVis.tsx b/src/vis/LazyVis.tsx index fe9f7e87b..084743770 100644 --- a/src/vis/LazyVis.tsx +++ b/src/vis/LazyVis.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import type { EagerVis } from './Vis'; +import type { EagerVis } from './EagerVis'; -const VisLazy = React.lazy(() => import('./Vis').then((m) => ({ default: m.EagerVis }))); +const VisLazy = React.lazy(() => import('./EagerVis').then((m) => ({ default: m.EagerVis }))); export function Vis(props: Parameters[0]) { return ( diff --git a/src/vis/Provider.tsx b/src/vis/Provider.tsx new file mode 100644 index 000000000..59dc021df --- /dev/null +++ b/src/vis/Provider.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { BaseVisConfig, ICommonVisProps, ICommonVisSideBarProps, VisColumn } from './interfaces'; + +/** + * The general visualization interface. Holds the type and the renderers. + */ +export interface GeneralVis { + type: string; + renderer: (props: ICommonVisProps) => React.JSX.Element; + sidebarRenderer: (props: ICommonVisSideBarProps) => React.JSX.Element; + mergeConfig: (columns: VisColumn[], config: T) => T; + description: string; +} + +/** + * Generic utility function for creating a vis object. + */ +export function createVis({ + type, + renderer, + sidebarRenderer, + mergeConfig, + description = '', +}: { + type: string; + + /** The main vis renderer. Required in all visualizations. */ + renderer: (props: ICommonVisProps) => React.JSX.Element; + + /** The sidebar renderer. Required in all visualizations. */ + sidebarRenderer: (props: ICommonVisSideBarProps) => React.JSX.Element; + + mergeConfig: (columns: VisColumn[], config: T) => T; + + description: string; +}): GeneralVis { + return { + type, + renderer, + sidebarRenderer, + mergeConfig, + description, + }; +} + +export const VisProviderContext = React.createContext<{ + visTypes: GeneralVis[]; + getVisByType: (type: string) => GeneralVis | null; + registerVisType: (...visType: GeneralVis[]) => void; +}>({ + visTypes: [], + getVisByType: () => null, + registerVisType: () => {}, +}); + +// Internal, only used by EagerVis +export function VisProvider({ children }: { children: React.ReactNode }) { + const [visTypes, setVisTypes] = React.useState([]); + + const registerVisType = React.useCallback((...visType: GeneralVis[]) => { + setVisTypes((prevVisTypes) => { + const toAdd = visType.filter((conf) => !prevVisTypes.find((e) => e.type === conf.type)); + if (toAdd.length === 0) { + return prevVisTypes; + } + return [...prevVisTypes, ...toAdd]; + }); + }, []); + + const getVisByType = React.useCallback( + (type: string) => { + return visTypes.find((visType) => visType.type === type); + }, + [visTypes], + ); + + const visContextValue = React.useMemo(() => ({ visTypes, registerVisType, getVisByType }), [visTypes, registerVisType, getVisByType]); + + return {children}; +} + +// Rather private, used by internal vis +export function useVisProvider() { + const context = React.useContext(VisProviderContext); + if (!context) { + throw Error('Vis can only be used as child of VisynAppProvider.'); + } + return context; +} diff --git a/src/vis/Vis.tsx b/src/vis/Vis.tsx deleted file mode 100644 index 3f996e8bd..000000000 --- a/src/vis/Vis.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import * as React from 'react'; -import * as d3v7 from 'd3v7'; -import { useMemo, useEffect } from 'react'; -import { useUncontrolled } from '@mantine/hooks'; -import { - ESupportedPlotlyVis, - IVisConfig, - Scales, - VisColumn, - EFilterOptions, - ENumericalColorScaleType, - EColumnTypes, - EBarDirection, - EBarDisplayType, - EBarGroupingType, - EScatterSelectSettings, - EAggregateTypes, -} from './interfaces'; -import { isScatter, scatterMergeDefaultConfig, ScatterVis } from './scatter'; -import { barMergeDefaultConfig, isBar, BarVis } from './bar'; -import { isViolin, violinMergeDefaultConfig, ViolinVis } from './violin'; -import { getCssValue } from '../utils'; -import { useSyncedRef } from '../hooks/useSyncedRef'; -import { hexinbMergeDefaultConfig, isHexbin } from './hexbin/utils'; -import { HexbinVis } from './hexbin/HexbinVis'; - -const DEFAULT_SHAPES = ['circle', 'square', 'triangle-up', 'star']; - -export function EagerVis({ - columns, - selected = [], - colors = null, - shapes = DEFAULT_SHAPES, - selectionCallback = () => null, - filterCallback = () => null, - setExternalConfig = () => null, - closeCallback = () => null, - showCloseButton = false, - externalConfig = null, - enableSidebar = true, - showSidebar: internalShowSidebar, - showDragModeOptions = true, - setShowSidebar: internalSetShowSidebar, - showSidebarDefault = false, - scrollZoom = true, -}: { - /** - * Required data columns which are displayed. - */ - columns: VisColumn[]; - /** - * Optional Prop for identifying which points are selected. Any ids that are in this array will be considered selected. - */ - selected?: string[]; - /** - * Optional Prop for changing the colors that are used in color mapping. Defaults to the Datavisyn categorical color scheme - */ - colors?: string[]; - /** - * Optional Prop for changing the shapes that are used in shape mapping. Defaults to the circle, square, triangle, star. - */ - shapes?: string[]; - /** - * Optional Prop which is called when a selection is made in the scatterplot visualization. Passes in the selected points. - */ - selectionCallback?: (s: string[]) => void; - /** - * Optional Prop which is called when a filter is applied. Returns a string identifying what type of filter is desired. This logic will be simplified in the future. - */ - filterCallback?: (s: EFilterOptions) => void; - setExternalConfig?: (config: IVisConfig) => void; - closeCallback?: () => void; - showCloseButton?: boolean; - externalConfig?: IVisConfig; - enableSidebar?: boolean; - showSidebar?: boolean; - showDragModeOptions?: boolean; - setShowSidebar?(show: boolean): void; - showSidebarDefault?: boolean; - scrollZoom?: boolean; -}) { - const [showSidebar, setShowSidebar] = useUncontrolled({ - value: internalShowSidebar, - defaultValue: showSidebarDefault, - finalValue: false, - onChange: internalSetShowSidebar, - }); - - // Each time you switch between vis config types, there is one render where the config is inconsistent with the type before the merge functions in the useEffect below can be called. - // To ensure that we never render an incosistent config, keep a consistent and a current in the config. Always render the consistent. - // eslint-disable-next-line @typescript-eslint/naming-convention - const [{ consistent: visConfig, current: inconsistentVisConfig }, _setVisConfig] = React.useState<{ - consistent: IVisConfig; - current: IVisConfig; - }>( - externalConfig - ? { consistent: null, current: externalConfig } - : columns.filter((c) => c.type === EColumnTypes.NUMERICAL).length > 1 - ? { - consistent: null, - current: { - type: ESupportedPlotlyVis.SCATTER, - numColumnsSelected: [], - color: null, - numColorScaleType: ENumericalColorScaleType.SEQUENTIAL, - shape: null, - dragMode: EScatterSelectSettings.RECTANGLE, - alphaSliderVal: 0.5, - }, - } - : { - consistent: null, - current: { - type: ESupportedPlotlyVis.BAR, - multiples: null, - group: null, - direction: EBarDirection.HORIZONTAL, - display: EBarDisplayType.ABSOLUTE, - groupType: EBarGroupingType.STACK, - numColumnsSelected: [], - catColumnSelected: null, - aggregateColumn: null, - aggregateType: EAggregateTypes.COUNT, - }, - }, - ); - - const setExternalConfigRef = useSyncedRef(setExternalConfig); - useEffect(() => { - setExternalConfigRef.current?.(visConfig); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(visConfig), setExternalConfigRef]); - - const setVisConfig = React.useCallback((newConfig: IVisConfig) => { - _setVisConfig((oldConfig) => { - return { - current: newConfig, - consistent: oldConfig.current.type !== newConfig.type ? oldConfig.consistent : newConfig, - }; - }); - }, []); - - React.useEffect(() => { - if (isScatter(inconsistentVisConfig)) { - const newConfig = scatterMergeDefaultConfig(columns, inconsistentVisConfig); - _setVisConfig({ current: newConfig, consistent: newConfig }); - } - if (isViolin(inconsistentVisConfig)) { - const newConfig = violinMergeDefaultConfig(columns, inconsistentVisConfig); - _setVisConfig({ current: newConfig, consistent: newConfig }); - } - if (isBar(inconsistentVisConfig)) { - const newConfig = barMergeDefaultConfig(columns, inconsistentVisConfig); - _setVisConfig({ current: newConfig, consistent: newConfig }); - } - if (isHexbin(inconsistentVisConfig)) { - const newConfig = hexinbMergeDefaultConfig(columns, inconsistentVisConfig); - _setVisConfig({ current: newConfig, consistent: newConfig }); - } - // DANGER:: this useEffect should only occur when the visConfig.type changes. adding visconfig into the dep array will cause an infinite loop. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inconsistentVisConfig.type]); - - useEffect(() => { - if (externalConfig) { - setVisConfig(externalConfig); - } - }, [externalConfig, setVisConfig]); - - // Converting the selected list into a map, since searching through the list to find an item is common in the vis components. - const selectedMap: { [key: string]: boolean } = useMemo(() => { - const currMap: { [key: string]: boolean } = {}; - - selected.forEach((s) => { - currMap[s] = true; - }); - - return currMap; - }, [selected]); - - const scales: Scales = useMemo(() => { - const colorScale = d3v7 - .scaleOrdinal() - .range( - colors || [ - getCssValue('visyn-c1'), - getCssValue('visyn-c2'), - getCssValue('visyn-c3'), - getCssValue('visyn-c4'), - getCssValue('visyn-c5'), - getCssValue('visyn-c6'), - getCssValue('visyn-c7'), - getCssValue('visyn-c8'), - getCssValue('visyn-c9'), - getCssValue('visyn-c10'), - ], - ); - - return { - color: colorScale, - }; - }, [colors]); - - if (!visConfig) { - return
; - } - - const commonProps = { - showSidebar, - setShowSidebar, - enableSidebar, - }; - - return ( - <> - {isScatter(visConfig) ? ( - - ) : null} - - {isViolin(visConfig) ? ( - - ) : null} - {isBar(visConfig) ? ( - - ) : null} - - {isHexbin(visConfig) ? ( - - ) : null} - - ); -} diff --git a/src/vis/VisSidebar.tsx b/src/vis/VisSidebar.tsx index 4335fe23e..5469e3ddf 100644 --- a/src/vis/VisSidebar.tsx +++ b/src/vis/VisSidebar.tsx @@ -1,66 +1,33 @@ import * as React from 'react'; -import { isBar } from './bar/utils'; -import { isScatter } from './scatter/utils'; -import { IVisConfig, VisColumn, ICommonVisSideBarProps } from './interfaces'; -import { isViolin } from './violin/utils'; -import { BarVisSidebar } from './bar/BarVisSidebar'; -import { ViolinVisSidebar } from './violin/ViolinVisSidebar'; -import { ScatterVisSidebar } from './scatter/ScatterVisSidebar'; +import { useVisProvider } from './Provider'; +import { ICommonVisSideBarProps } from './interfaces'; -export type VisSidebarProps = { - /** - * Required data columns which are displayed. - */ - columns: VisColumn[]; - /** - * Optional Prop which is called when a filter is applied. Returns a string identifying what type of filter is desired, either "Filter In", "Filter Out", or "Clear". This logic will be simplified in the future. - */ - filterCallback?: (s: string) => void; - externalConfig: IVisConfig; - setExternalConfig: (c: IVisConfig) => void; -} & ICommonVisSideBarProps; +export function VisSidebar({ + columns, + filterCallback = () => null, + optionsConfig, + config = null, + setConfig = null, + className, + style, +}: ICommonVisSideBarProps) { + const { getVisByType } = useVisProvider(); -export function VisSidebar({ columns, filterCallback = () => null, externalConfig = null, setExternalConfig = null, className, style }: VisSidebarProps) { - if (!externalConfig) { + if (!config) { return null; } - return ( - <> - {isScatter(externalConfig) ? ( - - ) : null} + const Renderer = getVisByType(config?.type)?.sidebarRenderer; - {isViolin(externalConfig) ? ( - - ) : null} - - {isBar(externalConfig) ? ( - - ) : null} - - ); + return Renderer ? ( + + ) : null; } diff --git a/src/vis/VisSidebarOpenButton.tsx b/src/vis/VisSidebarOpenButton.tsx index 8933e2430..634eef632 100644 --- a/src/vis/VisSidebarOpenButton.tsx +++ b/src/vis/VisSidebarOpenButton.tsx @@ -1,15 +1,14 @@ -import * as React from 'react'; -import { ActionIcon, Center, Container, Group, Stack, Tooltip } from '@mantine/core'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faGear } from '@fortawesome/free-solid-svg-icons/faGear'; -import { faClose } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { ActionIcon, Tooltip } from '@mantine/core'; +import * as React from 'react'; import { i18n } from '../i18n'; export function VisSidebarOpenButton({ isOpen, onClick }: { isOpen?: boolean; onClick: () => void }) { return ( - - + + ); diff --git a/src/vis/VisSidebarWrapper.tsx b/src/vis/VisSidebarWrapper.tsx index 3bad60c65..e3fa3cd0e 100644 --- a/src/vis/VisSidebarWrapper.tsx +++ b/src/vis/VisSidebarWrapper.tsx @@ -1,17 +1,33 @@ -import { Box, Divider, Drawer, Group, ScrollArea } from '@mantine/core'; +import { faClose } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { ActionIcon, Box, Divider, Group, ScrollArea, Stack, Text, Tooltip } from '@mantine/core'; import * as React from 'react'; import { ReactNode } from 'react'; +import { i18n } from '../i18n'; +import { VisTypeSelect } from './sidebar/VisTypeSelect'; +import { HelpHoverCard } from '../components/HelpHoverCard'; const sidebarSize = 200; -const padding = 10; -export function VisSidebarWrapper({ children }: { children: ReactNode }) { +export function VisSidebarWrapper({ children, config, setConfig, onClick }: { children: ReactNode; config; setConfig; onClick }) { return ( - {children} + + + Settings + + + + + + + setConfig({ ...config, type })} currentSelected={config.type} /> + + {children} + diff --git a/src/vis/bar/BarChart.tsx b/src/vis/bar/BarChart.tsx new file mode 100644 index 000000000..40ff0ecff --- /dev/null +++ b/src/vis/bar/BarChart.tsx @@ -0,0 +1,131 @@ +import { Box, Loader, SimpleGrid, Stack } from '@mantine/core'; +import { op } from 'arquero'; +import React, { useCallback, useMemo } from 'react'; +import { useAsync } from '../../hooks/useAsync'; +import { EColumnTypes, VisColumn } from '../interfaces'; +import { SingleBarChart } from './SingleBarChart'; +import { Legend } from './barComponents/Legend'; +import { useGetGroupedBarScales } from './hooks/useGetGroupedBarScales'; +import { getBarData } from './utils'; +import { IBarConfig, SortTypes } from './interfaces'; + +export function BarChart({ + config, + columns, + selectedMap, + selectedList, + selectionCallback, +}: { + config: IBarConfig; + columns: VisColumn[]; + selectedMap: Record; + selectedList: string[]; + selectionCallback?: (ids: string[]) => void; +}) { + const { value: allColumns, status: colsStatus } = useAsync(getBarData, [ + columns, + config.catColumnSelected, + config.group, + config.multiples, + config.aggregateColumn, + ]); + + const [sortType, setSortType] = React.useState(SortTypes.NONE); + + const uniqueMultiplesVals = useMemo(() => { + return [...new Set(allColumns?.multiplesColVals?.resolvedValues.map((v) => v.val))] as string[]; + }, [allColumns]); + + const { groupColorScale, groupedTable } = useGetGroupedBarScales( + allColumns, + 0, + 0, + { left: 0, top: 0, right: 0, bottom: 0 }, + null, + true, + selectedMap, + config.groupType, + sortType, + config.aggregateType, + ); + + const groupedIds = useMemo(() => { + if (!groupedTable) { + return []; + } + return groupedTable + .groupby('group') + .rollup({ ids: op.array_agg('ids') }) + .objects() + .map((val: { group: string; ids: string[][] }) => ({ group: val.group, ids: val.ids.flat() })); + }, [groupedTable]); + + const customSelectionCallback = useCallback( + (e: React.MouseEvent, ids: string[]) => { + if (e.ctrlKey) { + selectionCallback([...new Set([...selectedList, ...ids])]); + return; + } + if (selectionCallback) { + if (selectedList.length === ids.length && selectedList.every((value, index) => value === ids[index])) { + selectionCallback([]); + } else { + selectionCallback(ids); + } + } + }, + [selectedList, selectionCallback], + ); + + return ( + + + {groupColorScale ? ( + console.log('hello')} + stepSize={allColumns.groupColVals?.type === EColumnTypes.NUMERICAL ? groupedTable.get('group_max', 0) - groupedTable.get('group', 0) : 0} + /> + ) : null} + + + {colsStatus !== 'success' ? ( + + ) : !config.multiples || !allColumns.multiplesColVals ? ( + + ) : ( + uniqueMultiplesVals.map((multiplesVal) => ( + + )) + )} + + + ); +} diff --git a/src/vis/bar/BarDirectionButtons.tsx b/src/vis/bar/BarDirectionButtons.tsx new file mode 100644 index 000000000..371597b19 --- /dev/null +++ b/src/vis/bar/BarDirectionButtons.tsx @@ -0,0 +1,25 @@ +import { Input, SegmentedControl } from '@mantine/core'; +import * as React from 'react'; +import { EBarDirection } from './interfaces'; + +interface BarDirectionProps { + callback: (s: EBarDirection) => void; + currentSelected: EBarDirection; +} + +export function BarDirectionButtons({ callback, currentSelected }: BarDirectionProps) { + return ( + + + + ); +} diff --git a/src/vis/sidebar/BarDisplayTypeButtons.tsx b/src/vis/bar/BarDisplayTypeButtons.tsx similarity index 61% rename from src/vis/sidebar/BarDisplayTypeButtons.tsx rename to src/vis/bar/BarDisplayTypeButtons.tsx index 136d70894..54c5a6b2d 100644 --- a/src/vis/sidebar/BarDisplayTypeButtons.tsx +++ b/src/vis/bar/BarDisplayTypeButtons.tsx @@ -1,18 +1,20 @@ -import { Container, SegmentedControl, Stack, Text } from '@mantine/core'; +import { Container, SegmentedControl, Stack } from '@mantine/core'; import * as React from 'react'; -import { EBarDisplayType } from '../interfaces'; +import { EBarDisplayType } from './interfaces'; interface BarDisplayProps { callback: (s: EBarDisplayType) => void; currentSelected: EBarDisplayType; + isCount: boolean; } -export function BarDisplayButtons({ callback, currentSelected }: BarDisplayProps) { +export function BarDisplayButtons({ callback, currentSelected, isCount }: BarDisplayProps) { return ( void; diff --git a/src/vis/bar/BarVis.tsx b/src/vis/bar/BarVis.tsx index d185254ef..31c9b42fb 100644 --- a/src/vis/bar/BarVis.tsx +++ b/src/vis/bar/BarVis.tsx @@ -1,298 +1,19 @@ -import * as React from 'react'; -import * as d3v7 from 'd3v7'; -import merge from 'lodash/merge'; -import uniqueId from 'lodash/uniqueId'; -import difference from 'lodash/difference'; -import { useEffect, useMemo, useState } from 'react'; -import { Group, Space, Stack } from '@mantine/core'; -import { Scales, VisColumn, IVisConfig, IBarConfig, EBarGroupingType, EFilterOptions } from '../interfaces'; -import { PlotlyComponent } from '../../plotly'; -import { Plotly } from '../../plotly/full'; -import { InvalidCols } from '../general'; -import { beautifyLayout } from '../general/layoutUtils'; -import { useAsync } from '../../hooks'; -import { createBarTraces } from './utils'; -import { BarVisSidebar } from './BarVisSidebar'; -import { VisSidebarWrapper } from '../VisSidebarWrapper'; -import { CloseButton } from '../sidebar/CloseButton'; -import { VisSidebarOpenButton } from '../VisSidebarOpenButton'; +import { Stack } from '@mantine/core'; +import React from 'react'; -const defaultExtensions = { - prePlot: null, - postPlot: null, - preSidebar: null, - postSidebar: null, -}; - -export function BarVis({ - config, - optionsConfig, - extensions, - columns, - setConfig, - scales, - selectionCallback = () => null, - selectedMap = {}, - selectedList = [], - enableSidebar, - showSidebar, - setShowSidebar, - showCloseButton = false, - closeButtonCallback = () => null, - filterCallback = () => null, -}: { - config: IBarConfig; - optionsConfig?: { - group?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - multiples?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - direction?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - groupingType?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - display?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - }; - filterCallback?: (s: EFilterOptions) => void; - extensions?: { - prePlot?: React.ReactNode; - postPlot?: React.ReactNode; - preSidebar?: React.ReactNode; - postSidebar?: React.ReactNode; - }; - columns: VisColumn[]; - closeButtonCallback?: () => void; - showCloseButton?: boolean; - selectionCallback?: (ids: string[]) => void; - selectedMap?: { [key: string]: boolean }; - selectedList: string[]; - setConfig: (config: IVisConfig) => void; - scales: Scales; - showSidebar?: boolean; - setShowSidebar?(show: boolean): void; - enableSidebar?: boolean; -}) { - const mergedExtensions = React.useMemo(() => { - return merge({}, defaultExtensions, extensions); - }, [extensions]); - - const { value: traces, status: traceStatus, error: traceError } = useAsync(createBarTraces, [columns, config, scales]); - - const [layout, setLayout] = useState>(null); - - // Make sure selected values is right for each plot. - const finalTraces = useMemo(() => { - if (!traces) { - return null; - } - - let isTraceSelected = false; - - const editedTraces = { ...traces }; - - editedTraces?.plots.forEach((plot) => { - // custom data on each trace is the ids of every element in that section of the bar. - const tracePoints: string[][] = plot.data.customdata as string[][]; - - const selectedIndices = []; - tracePoints.forEach((points, index) => { - if (points.length === 0) { - return; - } - for (const point of points) { - if (selectedMap[point]) { - isTraceSelected = true; - selectedIndices.push(index); - } - } - }); - - if (selectedIndices.length > 0) { - plot.data.selectedpoints = selectedIndices; - } else { - plot.data.selectedpoints = null; - } - }); - - if (isTraceSelected) { - editedTraces?.plots.forEach((plot) => { - if (plot.data.selectedpoints === null) { - plot.data.selectedpoints = []; - } - }); - } - - return editedTraces; - }, [traces, selectedMap]); - - const id = React.useMemo(() => uniqueId('BarVis'), []); - - const plotlyDivRef = React.useRef(null); - - useEffect(() => { - const ro = new ResizeObserver(() => { - const plotDiv = document.getElementById(`plotlyDiv${id}`); - if (plotDiv) { - Plotly.Plots.resize(plotDiv); - } - }); - - if (plotlyDivRef) { - ro.observe(plotlyDivRef.current); - } - - return () => ro.disconnect(); - }, [id, plotlyDivRef]); - - React.useEffect(() => { - if (!finalTraces) { - return; - } - - const innerLayout: Partial = { - showlegend: true, - legend: { - // @ts-ignore - itemclick: false, - itemdoubleclick: false, - }, - font: { - family: 'Roboto, sans-serif', - }, - margin: { - t: 40, - r: 25, - l: 25, - b: 25, - }, - autosize: true, - grid: { rows: finalTraces.rows, columns: finalTraces.cols, xgap: 0.3, pattern: 'independent' }, - shapes: [], - barmode: config.groupType === EBarGroupingType.STACK ? 'stack' : 'group', - dragmode: false, - }; - - setLayout({ ...layout, ...beautifyLayout(finalTraces, innerLayout, null, true) }); - // WARNING: Do not update when layout changes, that would be an infinite loop. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [finalTraces, config.groupType]); - - const traceData = useMemo(() => { - if (!finalTraces) { - return null; - } - - return [...finalTraces.plots.map((p) => p.data), ...finalTraces.legendPlots.map((p) => p.data)]; - }, [finalTraces]); +import { InvalidCols } from '../general/InvalidCols'; +import { ICommonVisProps } from '../interfaces'; +import { BarChart } from './BarChart'; +import { IBarConfig } from './interfaces'; +export function BarVis({ config, columns, selectionCallback = () => null, selectedMap = {}, selectedList = [] }: ICommonVisProps) { return ( - - {enableSidebar ? setShowSidebar(!showSidebar)} isOpen={showSidebar} /> : null} - - - {showCloseButton ? : null} - - {mergedExtensions.prePlot} - - - {traceStatus === 'success' && layout && finalTraces?.plots.length > 0 ? ( - { - // plotly types here are just wrong. So have to convert to unknown first. - const selectedPoints: string[] = e.points[0].customdata as unknown as string[]; - - let removeSelectionFlag = false; - - if (selectedPoints.length === selectedList.length) { - removeSelectionFlag = true; - - for (const pointId of selectedPoints) { - if (!selectedMap[pointId]) { - removeSelectionFlag = false; - break; - } - } - } - - if (removeSelectionFlag) { - const newList = difference(selectedList, selectedPoints); - selectionCallback(newList); - } else if (e.event.ctrlKey) { - const newList = Array.from(new Set([...selectedList, ...selectedPoints])); - selectionCallback(newList); - } else { - selectionCallback(selectedPoints); - } - }} - // plotly redraws everything on updates, so you need to reappend title and - onUpdate={() => { - for (const p of finalTraces.plots) { - d3v7.select(`g .${p.data.xaxis}title`).style('pointer-events', 'all').append('title').text(p.xLabel); - - d3v7.select(`g .${p.data.yaxis}title`).style('pointer-events', 'all').append('title').text(p.yLabel); - } - }} - /> - ) : traceStatus !== 'pending' && traceStatus !== 'idle' && layout ? ( - - ) : null} - {mergedExtensions.postPlot} - - {showSidebar && plotlyDivRef?.current ? ( - - - - ) : null} - + + {config.catColumnSelected ? ( + + ) : ( + + )} + ); } diff --git a/src/vis/bar/BarVisSidebar.tsx b/src/vis/bar/BarVisSidebar.tsx index ceb783dba..755fb983b 100644 --- a/src/vis/bar/BarVisSidebar.tsx +++ b/src/vis/bar/BarVisSidebar.tsx @@ -1,27 +1,13 @@ +import merge from 'lodash/merge'; import * as React from 'react'; import { useMemo } from 'react'; -import merge from 'lodash/merge'; -import { Container, Divider, Stack } from '@mantine/core'; -import { - ColumnInfo, - EBarDirection, - EBarDisplayType, - EBarGroupingType, - ESupportedPlotlyVis, - IBarConfig, - IVisConfig, - VisColumn, - ICommonVisSideBarProps, - EAggregateTypes, - EColumnTypes, - EFilterOptions, -} from '../interfaces'; -import { VisTypeSelect } from '../sidebar/VisTypeSelect'; -import { GroupSelect } from '../sidebar/GroupSelect'; -import { BarDirectionButtons } from '../sidebar/BarDirectionButtons'; -import { SingleColumnSelect } from '../sidebar/SingleColumnSelect'; +import { ColumnInfo, EAggregateTypes, EColumnTypes, ICommonVisSideBarProps } from '../interfaces'; import { AggregateTypeSelect } from '../sidebar/AggregateTypeSelect'; import { FilterButtons } from '../sidebar/FilterButtons'; +import { SingleColumnSelect } from '../sidebar/SingleColumnSelect'; +import { BarDirectionButtons } from './BarDirectionButtons'; +import { GroupSelect } from './GroupSelect'; +import { EBarDirection, EBarDisplayType, EBarGroupingType, IBarConfig } from './interfaces'; const defaultConfig = { group: { @@ -50,139 +36,85 @@ const defaultConfig = { }, }; -const defaultExtensions = { - prePlot: null, - postPlot: null, - preSidebar: null, - postSidebar: null, -}; - export function BarVisSidebar({ config, optionsConfig, - extensions, columns, - filterCallback = () => null, setConfig, className = '', + filterCallback, style: { width = '20em', ...style } = {}, -}: { - config: IBarConfig; - optionsConfig?: { - group?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - multiples?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - direction?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - groupingType?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - filter?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - display?: { - enable?: boolean; - customComponent?: React.ReactNode; - }; - }; - extensions?: { - prePlot?: React.ReactNode; - postPlot?: React.ReactNode; - preSidebar?: React.ReactNode; - postSidebar?: React.ReactNode; - }; - filterCallback?: (s: EFilterOptions) => void; - columns: VisColumn[]; - setConfig: (config: IVisConfig) => void; -} & ICommonVisSideBarProps) { +}: ICommonVisSideBarProps) { const mergedOptionsConfig = useMemo(() => { return merge({}, defaultConfig, optionsConfig); }, [optionsConfig]); - const mergedExtensions = useMemo(() => { - return merge({}, defaultExtensions, extensions); - }, [extensions]); - return ( - - setConfig({ ...(config as any), type })} currentSelected={config.type} /> - - - + <> + + setConfig({ + ...config, + catColumnSelected, + multiples: config.multiples && config.multiples.id === catColumnSelected?.id ? null : config.multiples, + group: config.group && config.group.id === catColumnSelected?.id ? null : config.group, + }) + } + columns={columns} + currentSelected={config.catColumnSelected} + type={[EColumnTypes.CATEGORICAL]} + label="Categorical column" + /> + { + if (config.aggregateColumn === null) { setConfig({ ...config, - catColumnSelected, - multiples: config.multiples && config.multiples.id === catColumnSelected?.id ? null : config.multiples, - group: config.group && config.group.id === catColumnSelected?.id ? null : config.group, - }) + aggregateType, + aggregateColumn: columns.find((col) => col.type === EColumnTypes.NUMERICAL).info, + display: aggregateType === EAggregateTypes.COUNT ? config.display : EBarDisplayType.ABSOLUTE, + }); + } else { + setConfig({ ...config, aggregateType, display: aggregateType === EAggregateTypes.COUNT ? config.display : EBarDisplayType.ABSOLUTE }); } - columns={columns} - currentSelected={config.catColumnSelected} - type={[EColumnTypes.CATEGORICAL]} - label="Categorical column" - /> - { - if (config.aggregateColumn === null) { - setConfig({ ...config, aggregateType, aggregateColumn: columns.find((col) => col.type === EColumnTypes.NUMERICAL).info }); - } else { - setConfig({ ...config, aggregateType }); - } - }} - aggregateColumnSelectCallback={(aggregateColumn: ColumnInfo) => setConfig({ ...config, aggregateColumn })} - columns={columns} - currentSelected={config.aggregateType} - aggregateColumn={config.aggregateColumn} - /> - - - {mergedExtensions.preSidebar} + }} + aggregateColumnSelectCallback={(aggregateColumn: ColumnInfo) => setConfig({ ...config, aggregateColumn })} + columns={columns} + currentSelected={config.aggregateType} + aggregateColumn={config.aggregateColumn} + /> - - {mergedOptionsConfig.group.enable - ? mergedOptionsConfig.group.customComponent || ( - setConfig({ ...config, group })} - groupTypeSelectCallback={(groupType: EBarGroupingType) => setConfig({ ...config, groupType })} - groupDisplaySelectCallback={(display: EBarDisplayType) => setConfig({ ...config, display })} - displayType={config.display} - groupType={config.groupType} - columns={columns.filter((c) => config.catColumnSelected && c.info.id !== config.catColumnSelected.id)} - currentSelected={config.group} - /> - ) - : null} - {mergedOptionsConfig.multiples.enable - ? mergedOptionsConfig.multiples.customComponent || ( - setConfig({ ...config, multiples })} - columns={columns.filter((c) => config.catColumnSelected && c.info.id !== config.catColumnSelected.id)} - currentSelected={config.multiples} - label="Multiples" - type={[EColumnTypes.CATEGORICAL]} - /> - ) - : null} - - + {mergedOptionsConfig.group.enable + ? mergedOptionsConfig.group.customComponent || ( + setConfig({ ...config, group })} + groupTypeSelectCallback={(groupType: EBarGroupingType) => setConfig({ ...config, groupType })} + groupDisplaySelectCallback={(display: EBarDisplayType) => setConfig({ ...config, display })} + displayType={config.display} + groupType={config.groupType} + columns={columns.filter((c) => config.catColumnSelected && c.info.id !== config.catColumnSelected.id)} + currentSelected={config.group} + /> + ) + : null} + {mergedOptionsConfig.multiples.enable + ? mergedOptionsConfig.multiples.customComponent || ( + setConfig({ ...config, multiples })} + columns={columns.filter((c) => config.catColumnSelected && c.info.id !== config.catColumnSelected.id)} + currentSelected={config.multiples} + label="Multiples" + type={[EColumnTypes.CATEGORICAL]} + /> + ) + : null} {mergedOptionsConfig.direction.enable ? mergedOptionsConfig.direction.customComponent || ( setConfig({ ...config, direction })} currentSelected={config.direction} /> ) : null} {mergedOptionsConfig.filter.enable ? mergedOptionsConfig.filter.customComponent || : null} - - {mergedExtensions.postSidebar} - + ); } diff --git a/src/vis/sidebar/GroupSelect.tsx b/src/vis/bar/GroupSelect.tsx similarity index 67% rename from src/vis/sidebar/GroupSelect.tsx rename to src/vis/bar/GroupSelect.tsx index da3e8123f..078a123e6 100644 --- a/src/vis/sidebar/GroupSelect.tsx +++ b/src/vis/bar/GroupSelect.tsx @@ -1,9 +1,10 @@ import { Select, Stack } from '@mantine/core'; import * as React from 'react'; -import { ColumnInfo, EBarDisplayType, EBarGroupingType, EColumnTypes, VisColumn } from '../interfaces'; +import { EBarDisplayType, EBarGroupingType } from './interfaces'; +import { ColumnInfo, EAggregateTypes, EColumnTypes, VisColumn } from '../interfaces'; import { BarDisplayButtons } from './BarDisplayTypeButtons'; import { BarGroupTypeButtons } from './BarGroupTypeButtons'; -import { SelectDropdownItem } from './utils'; +import { SelectDropdownItem } from '../sidebar/utils'; interface GroupSelectProps { groupColumnSelectCallback: (c: ColumnInfo) => void; @@ -13,6 +14,7 @@ interface GroupSelectProps { displayType: EBarDisplayType; columns: VisColumn[]; currentSelected: ColumnInfo | null; + aggregateType: EAggregateTypes; } export function GroupSelect({ @@ -23,6 +25,7 @@ export function GroupSelect({ displayType, columns, currentSelected, + aggregateType, }: GroupSelectProps) { return ( @@ -30,19 +33,23 @@ export function GroupSelect({ withinPortal clearable itemComponent={SelectDropdownItem} - placeholder="Select Column" + placeholder="Select columns" label="Group" onChange={(e) => groupColumnSelectCallback(columns.find((c) => c.info.id === e)?.info)} data={columns - .filter((c) => c.type === EColumnTypes.CATEGORICAL) + .filter((c) => c.type === EColumnTypes.CATEGORICAL || c.type === EColumnTypes.NUMERICAL) .map((c) => ({ value: c.info.id, label: c.info.name, description: c.info.description }))} - value={currentSelected?.id} + value={currentSelected?.id || null} /> {currentSelected ? ( groupTypeSelectCallback(newGroupType)} currentSelected={groupType} /> ) : null} {currentSelected && groupType === EBarGroupingType.STACK ? ( - groupDisplaySelectCallback(display)} currentSelected={displayType} /> + groupDisplaySelectCallback(display)} + currentSelected={displayType} + isCount={aggregateType === EAggregateTypes.COUNT} + /> ) : null} ); diff --git a/src/vis/bar/SingleBarChart.tsx b/src/vis/bar/SingleBarChart.tsx new file mode 100644 index 000000000..c92cde259 --- /dev/null +++ b/src/vis/bar/SingleBarChart.tsx @@ -0,0 +1,279 @@ +import { Box, Container } from '@mantine/core'; +import { useResizeObserver } from '@mantine/hooks'; +import React, { useCallback, useMemo } from 'react'; +import { EAggregateTypes } from '../interfaces'; +import { XAxis } from './barComponents/XAxis'; +import { YAxis } from './barComponents/YAxis'; +import { GroupedBars } from './barTypes/GroupedBars'; +import { SimpleBars } from './barTypes/SimpleBars'; +import { StackedBars } from './barTypes/StackedBars'; +import { useGetGroupedBarScales } from './hooks/useGetGroupedBarScales'; +import { getBarData } from './utils'; +import { EBarDirection, EBarDisplayType, EBarGroupingType, IBarConfig, SortTypes } from './interfaces'; + +const margin = { + top: 30, + bottom: 60, + left: 60, + right: 25, +}; + +export function SingleBarChart({ + allColumns, + config, + categoryFilter, + title, + selectedMap, + selectedList, + selectionCallback, + isSmall = false, + sortType, + setSortType, +}: { + allColumns: Awaited>; + config: IBarConfig; + selectedMap: Record; + selectedList: string[]; + categoryFilter?: string; + title?: string; + selectionCallback?: (e: React.MouseEvent, ids: string[]) => void; + isSmall?: boolean; + sortType: SortTypes; + setSortType: (sortType: SortTypes) => void; +}) { + const [ref, { height, width }] = useResizeObserver(); + + const { aggregatedTable, categoryScale, countScale, groupColorScale, groupScale, groupedTable } = useGetGroupedBarScales( + allColumns, + height, + width, + margin, + categoryFilter, + config.direction === EBarDirection.VERTICAL, + selectedMap, + config.groupType, + sortType, + config.aggregateType, + ); + + const categoryTicks = useMemo(() => { + return categoryScale?.domain().map((value) => ({ + value, + offset: categoryScale(value) + categoryScale.bandwidth() / 2, + })); + }, [categoryScale]); + + const normalizedCountScale = useMemo(() => { + if (config.display === EBarDisplayType.NORMALIZED && config.groupType === EBarGroupingType.STACK && config.group) { + return countScale.copy().domain([0, 1]); + } + return countScale; + }, [config.display, config.group, config.groupType, countScale]); + + const countTicks = useMemo(() => { + if (!normalizedCountScale) { + return null; + } + if (config.direction !== EBarDirection.VERTICAL) { + const newScale = normalizedCountScale.copy().domain([normalizedCountScale.domain()[1], normalizedCountScale.domain()[0]]); + return newScale.ticks(5).map((value) => ({ + value, + offset: newScale(value), + })); + } + return normalizedCountScale.ticks(5).map((value) => ({ + value, + offset: normalizedCountScale(value), + })); + }, [config.direction, normalizedCountScale]); + + const sortTypeCallback = useCallback( + (label: string) => { + if (label === config.catColumnSelected.name) { + if (sortType === SortTypes.CAT_ASC) { + setSortType(SortTypes.CAT_DESC); + } else if (sortType === SortTypes.CAT_DESC) { + setSortType(SortTypes.NONE); + } else { + setSortType(SortTypes.CAT_ASC); + } + } else if (sortType === SortTypes.COUNT_ASC) { + setSortType(SortTypes.COUNT_DESC); + } else if (sortType === SortTypes.COUNT_DESC) { + setSortType(SortTypes.NONE); + } else { + setSortType(SortTypes.COUNT_ASC); + } + }, + [config.catColumnSelected.name, setSortType, sortType], + ); + + return ( + + + + + {countScale && categoryScale ? ( + + {title} + + ) : null} + selectionCallback(e, [])} + /> + + {countScale && categoryScale ? ( + config.direction === EBarDirection.VERTICAL ? ( + + ) : ( + + ) + ) : null} + {categoryScale && countScale ? ( + config.direction === EBarDirection.VERTICAL ? ( + + ) : ( + + ) + ) : null} + {config.group ? ( + config.groupType === EBarGroupingType.GROUP ? ( + 0} + groupedTable={groupedTable} + groupScale={groupScale} + categoryScale={categoryScale} + countScale={countScale} + groupColorScale={groupColorScale} + width={width} + height={height} + margin={margin} + aggregateType={config.aggregateType} + isVertical={config.direction === EBarDirection.VERTICAL} + aggregateColumnName={config.aggregateColumn?.name} + /> + ) : ( + 0} + groupedTable={groupedTable} + categoryScale={categoryScale} + countScale={countScale} + groupColorScale={groupColorScale} + height={height} + margin={margin} + width={width} + isVertical={config.direction === EBarDirection.VERTICAL} + normalized={config.display === EBarDisplayType.NORMALIZED} + aggregateType={config.aggregateType} + aggregateColumnName={config.aggregateColumn?.name} + /> + ) + ) : ( + 0} + selectionCallback={selectionCallback} + aggregatedTable={aggregatedTable} + categoryScale={categoryScale} + countScale={countScale} + height={height} + margin={margin} + width={width} + aggregateType={config.aggregateType} + isVertical={config.direction === EBarDirection.VERTICAL} + aggregateColumnName={config.aggregateColumn?.name} + /> + )} + + + + + ); +} diff --git a/src/vis/bar/barComponents/Legend.tsx b/src/vis/bar/barComponents/Legend.tsx new file mode 100644 index 000000000..6432fd318 --- /dev/null +++ b/src/vis/bar/barComponents/Legend.tsx @@ -0,0 +1,59 @@ +import React, { useMemo } from 'react'; +import * as d3v7 from 'd3v7'; +import { Stack, Chip, Tooltip, Box, ScrollArea, Group, Text, Center } from '@mantine/core'; + +export function Legend({ + categories, + colorScale, + onClick, + height, + groupedIds, + left, + isNumerical = false, + stepSize = 0, + selectedList, + selectionCallback, +}: { + categories: string[]; + groupedIds: { group: string; ids: string[] }[]; + colorScale: d3v7.ScaleOrdinal; + onClick: (string) => void; + height: number; + left: number; + isNumerical?: boolean; + stepSize?: number; + selectedList: string[]; + selectionCallback: (e: React.MouseEvent, ids: string[]) => void; +}) { + const selectedCat = useMemo(() => { + return categories.find((cat) => { + const myIds = groupedIds.find((group) => group.group === cat)?.ids || []; + return selectedList.length === myIds.length && selectedList.every((value, index) => value === myIds[index]); + }); + }, [categories, groupedIds, selectedList]); + + return ( + + + {categories.map((c) => { + const myIds = groupedIds.find((group) => group.group === c)?.ids || []; + + return ( + + selectionCallback(e, myIds)} style={{ cursor: 'pointer' }}> + + + +
+ onClick(c)}> + {isNumerical ? `${c} - ${+c + stepSize}` : c} + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/vis/bar/barComponents/SingleBar.tsx b/src/vis/bar/barComponents/SingleBar.tsx new file mode 100644 index 000000000..22eab2c9e --- /dev/null +++ b/src/vis/bar/barComponents/SingleBar.tsx @@ -0,0 +1,68 @@ +import { Tooltip } from '@mantine/core'; +import React from 'react'; +import { animated, useSpring, easings } from 'react-spring'; + +export function SingleBar({ + selectedPercent, + x, + width, + y, + height, + tooltip, + color = '#878E95', + isVertical = true, + onClick, +}: { + selectedPercent: number | null; + x: number; + width: number; + y: number; + height: number; + tooltip?: JSX.Element; + color?: string; + isVertical?: boolean; + onClick?: (e: React.MouseEvent) => void; +}) { + const style = useSpring({ + config: { + duration: 500, + easing: easings.easeOutSine, + }, + immediate: true, + to: { + x, + y, + width, + height, + }, + }); + + const selectedRectStyle = useSpring({ + config: { + duration: 500, + easing: easings.easeOutSine, + }, + immediate: true, + to: { + x, + y: isVertical ? y + height - height * selectedPercent : y, + width: isVertical ? width : width * selectedPercent, + height: isVertical ? height * selectedPercent : height, + }, + }); + + return ( + + onClick(e)}> + {selectedPercent === null ? ( + + ) : ( + + + + + )} + + + ); +} diff --git a/src/vis/bar/barComponents/XAxis.tsx b/src/vis/bar/barComponents/XAxis.tsx new file mode 100644 index 000000000..c3ff07e53 --- /dev/null +++ b/src/vis/bar/barComponents/XAxis.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { useMemo } from 'react'; +import * as d3 from 'd3v7'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCaretLeft, faCaretRight } from '@fortawesome/free-solid-svg-icons'; +import { Center, Group, Text, Tooltip } from '@mantine/core'; +import { SortTypes } from '../interfaces'; + +// code taken from https://wattenberger.com/blog/react-and-d3 +export function XAxis({ + xScale, + yRange, + vertPosition, + label, + ticks, + showLines, + compact = false, + sortType, + arrowAsc = false, + arrowDesc = false, + setSortType, +}: { + showLines?: boolean; + xScale: d3.ScaleBand | d3.ScaleLinear; + yRange: [number, number]; + vertPosition: number; + label: string; + ticks: { value: string | number; offset: number }[]; + compact?: boolean; + sortType: SortTypes; + arrowAsc?: boolean; + arrowDesc?: boolean; + setSortType: (label: string) => void; +}) { + const tickWidth = useMemo(() => { + if (ticks.length > 1) { + return Math.abs(ticks[1].offset - ticks[0].offset); + } + + return xScale.range()[0] - xScale.range()[1]; + }, [ticks, xScale]); + return ( + <> + + +
+ + {arrowDesc ? : null} + + setSortType(label)}> + {label} + + {arrowAsc ? : null} + +
+
+
+ + + + {ticks.map(({ value, offset }) => ( + + + {showLines ? : null} + +
+ + + {value} + + +
+
+
+ ))} + + ); +} diff --git a/src/vis/bar/barComponents/YAxis.tsx b/src/vis/bar/barComponents/YAxis.tsx new file mode 100644 index 000000000..56ddb1576 --- /dev/null +++ b/src/vis/bar/barComponents/YAxis.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { useMemo } from 'react'; +import { Center, Group, Text } from '@mantine/core'; +import * as d3 from 'd3v7'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCaretLeft, faCaretRight } from '@fortawesome/free-solid-svg-icons'; +import { SortTypes } from '../interfaces'; + +type IsEqual = Type1 | Type2 extends Type1 & Type2 ? true : never; + +// code taken from https://wattenberger.com/blog/react-and-d3 +export function YAxis({ + yScale, + xRange, + horizontalPosition, + label, + ticks, + showLines, + compact = false, + arrowAsc = false, + arrowDesc = false, + sortType, + setSortType, +}: { + yScale: d3.ScaleBand | d3.ScaleLinear; + xRange: [number, number]; + horizontalPosition: number; + label: string; + ticks: { value: string | number; offset: number }[]; + showLines?: boolean; + compact?: boolean; + arrowAsc?: boolean; + arrowDesc?: boolean; + sortType: SortTypes; + setSortType: (label: string) => void; +}) { + const labelSpacing = useMemo(() => { + const maxLabelLength = ticks.reduce((max, { value }) => { + const { length } = `${value}`; + return length > max ? length : max; + }, 0); + + return maxLabelLength > 5 ? 30 : maxLabelLength * 6; + }, [ticks]); + + return ( + <> + + +
+ + {arrowDesc ? : null} + + setSortType(label)}> + {label} + + {arrowAsc ? : null} + +
+
+
+ + + {ticks.map(({ value, offset }) => ( + + + {showLines ? : null} + + + + + {value} + + + + + + ))} + + ); +} diff --git a/src/vis/bar/barTypes/GroupedBars.tsx b/src/vis/bar/barTypes/GroupedBars.tsx new file mode 100644 index 000000000..fd48c01a7 --- /dev/null +++ b/src/vis/bar/barTypes/GroupedBars.tsx @@ -0,0 +1,93 @@ +import React, { useMemo } from 'react'; +import * as d3 from 'd3v7'; + +import ColumnTable from 'arquero/dist/types/table/column-table'; + +import { Stack, Text } from '@mantine/core'; +import { SingleBar } from '../barComponents/SingleBar'; +import { EAggregateTypes } from '../../interfaces'; + +export function GroupedBars({ + groupedTable, + categoryScale, + countScale, + categoryName, + groupName, + height, + margin, + width, + groupScale, + groupColorScale, + isVertical = true, + hasSelected = false, + selectionCallback, + aggregateType, + aggregateColumnName = null, +}: { + groupedTable: ColumnTable; + categoryScale: d3.ScaleBand; + countScale: d3.ScaleLinear; + categoryName: string; + groupName: string; + groupScale: d3.ScaleBand; + groupColorScale: d3.ScaleOrdinal; + height: number; + width: number; + margin: { top: number; bottom: number; left: number; right: number }; + isVertical?: boolean; + selectionCallback: (e: React.MouseEvent, ids: string[]) => void; + hasSelected?: boolean; + aggregateType?: EAggregateTypes; + aggregateColumnName?: string; +}) { + const bars = useMemo(() => { + if (groupedTable && width !== 0 && height !== 0) { + return groupedTable + .orderby('category', 'group') + .objects() + .map((row: { category: string; group: string; count: number; aggregateVal: number; selectedCount: number; ids: string[] }) => { + return ( + selectionCallback(e, row.ids)} + isVertical={isVertical} + selectedPercent={hasSelected ? row.selectedCount / row.count : null} + key={row.category + row.group} + x={isVertical ? categoryScale(row.category) + groupScale(row.group) : margin.left} + width={isVertical ? groupScale.bandwidth() : width - margin.right - countScale(row.aggregateVal)} + y={isVertical ? countScale(row.aggregateVal) : categoryScale(row.category) + groupScale(row.group)} + tooltip={ + + {`${categoryName}: ${row.category}`} + {`${groupName}: ${row.group}`} + {`${aggregateType}${aggregateColumnName ? ` ${aggregateColumnName}` : ''}: ${row.aggregateVal}`} + + } + height={isVertical ? height - margin.bottom - countScale(row.aggregateVal) : groupScale.bandwidth()} + color={groupColorScale(row.group)} + /> + ); + }); + } + return null; + }, [ + groupedTable, + width, + height, + isVertical, + hasSelected, + categoryScale, + groupScale, + margin.left, + margin.right, + margin.bottom, + countScale, + categoryName, + groupName, + aggregateType, + aggregateColumnName, + groupColorScale, + selectionCallback, + ]); + + return {bars}; +} diff --git a/src/vis/bar/barTypes/SimpleBars.tsx b/src/vis/bar/barTypes/SimpleBars.tsx new file mode 100644 index 000000000..d8f3fef3d --- /dev/null +++ b/src/vis/bar/barTypes/SimpleBars.tsx @@ -0,0 +1,81 @@ +import React, { useMemo } from 'react'; + +import * as d3 from 'd3v7'; + +import ColumnTable from 'arquero/dist/types/table/column-table'; + +import { Stack, Text } from '@mantine/core'; +import { SingleBar } from '../barComponents/SingleBar'; +import { EAggregateTypes } from '../../interfaces'; + +export function SimpleBars({ + aggregatedTable, + categoryScale, + categoryName, + countScale, + height, + width, + margin, + isVertical = true, + selectionCallback, + hasSelected = false, + aggregateType, + aggregateColumnName = null, +}: { + aggregatedTable: ColumnTable; + categoryScale: d3.ScaleBand; + categoryName: string; + countScale: d3.ScaleLinear; + height: number; + width: number; + margin: { top: number; bottom: number; left: number; right: number }; + isVertical?: boolean; + selectionCallback: (e: React.MouseEvent, ids: string[]) => void; + hasSelected?: boolean; + aggregateType: EAggregateTypes; + aggregateColumnName?: string; +}) { + const bars = useMemo(() => { + if (aggregatedTable && categoryScale && countScale && width !== 0 && height !== 0) { + return aggregatedTable.objects().map((row: { category: string; count: number; aggregateVal: number; selectedCount: number; ids: string[] }) => { + return ( + selectionCallback(e, row.ids)} + isVertical={isVertical} + selectedPercent={hasSelected ? row.selectedCount / row.count : null} + key={row.category} + x={isVertical ? categoryScale(row.category) : margin.left} + width={isVertical ? categoryScale.bandwidth() : width - margin.right - countScale(row.aggregateVal)} + y={isVertical ? countScale(row.aggregateVal) : categoryScale(row.category)} + tooltip={ + + {`${categoryName}: ${row.category}`} + {`${aggregateType}${aggregateColumnName ? ` ${aggregateColumnName}` : ''}: ${row.aggregateVal}`} + + } + height={isVertical ? height - margin.bottom - countScale(row.aggregateVal) : categoryScale.bandwidth()} + /> + ); + }); + } + + return null; + }, [ + aggregatedTable, + categoryScale, + countScale, + width, + height, + isVertical, + hasSelected, + margin.left, + margin.right, + margin.bottom, + categoryName, + aggregateType, + aggregateColumnName, + selectionCallback, + ]); + + return {bars}; +} diff --git a/src/vis/bar/barTypes/StackedBars.tsx b/src/vis/bar/barTypes/StackedBars.tsx new file mode 100644 index 000000000..2d3f82282 --- /dev/null +++ b/src/vis/bar/barTypes/StackedBars.tsx @@ -0,0 +1,108 @@ +import React, { useMemo } from 'react'; +import * as d3 from 'd3v7'; + +import ColumnTable from 'arquero/dist/types/table/column-table'; +import { Stack, Text } from '@mantine/core'; +import { SingleBar } from '../barComponents/SingleBar'; +import { EAggregateTypes } from '../../interfaces'; + +export function StackedBars({ + groupedTable, + categoryScale, + countScale, + categoryName, + groupName, + height, + margin, + groupColorScale, + width, + normalized = false, + isVertical, + selectionCallback, + hasSelected, + aggregateType, + aggregateColumnName, +}: { + groupedTable: ColumnTable; + categoryScale: d3.ScaleBand; + categoryName: string; + groupName: string; + countScale: d3.ScaleLinear; + groupColorScale: d3.ScaleOrdinal; + height: number; + margin: { top: number; bottom: number; left: number; right: number }; + width: number; + normalized?: boolean; + isVertical; + selectionCallback: (e: React.MouseEvent, ids: string[]) => void; + hasSelected?: boolean; + aggregateType: EAggregateTypes; + aggregateColumnName?: string; +}) { + const bars = useMemo(() => { + if (groupedTable && width !== 0 && height !== 0) { + let heightSoFar = 0; + let currentCategory = ''; + + return groupedTable + .orderby('category', 'group') + .objects() + .map((row: { category: string; group: string; count: number; aggregateVal: number; categoryCount: number; selectedCount: number; ids: string[] }) => { + if (currentCategory !== row.category) { + heightSoFar = 0; + currentCategory = row.category; + } + + const myHeight = heightSoFar; + const normalizedCount = normalized ? countScale((countScale.domain()[1] / row.categoryCount) * row.aggregateVal) : countScale(row.aggregateVal); + if (isVertical) { + heightSoFar = myHeight + height - margin.bottom - normalizedCount; + } else { + heightSoFar = myHeight + width - margin.right - normalizedCount; + } + + return ( + selectionCallback(e, row.ids)} + isVertical={isVertical} + selectedPercent={hasSelected ? row.selectedCount / row.count : null} + key={row.category + row.group} + x={isVertical ? categoryScale(row.category) : margin.left + myHeight} + width={isVertical ? categoryScale.bandwidth() : width - margin.right - normalizedCount} + y={isVertical ? normalizedCount - myHeight : categoryScale(row.category)} + tooltip={ + + {`${categoryName}: ${row.category}`} + {`${groupName}: ${row.group}`} + {`${aggregateType}${aggregateColumnName ? ` ${aggregateColumnName}` : ''}: ${row.aggregateVal}`} + + } + height={isVertical ? height - margin.bottom - normalizedCount : categoryScale.bandwidth()} + color={groupColorScale(row.group)} + /> + ); + }); + } + return null; + }, [ + aggregateColumnName, + aggregateType, + categoryName, + categoryScale, + countScale, + groupColorScale, + groupName, + groupedTable, + hasSelected, + height, + isVertical, + margin.bottom, + margin.left, + margin.right, + normalized, + selectionCallback, + width, + ]); + + return {bars}; +} diff --git a/src/vis/bar/hooks/useGetBarScales.ts b/src/vis/bar/hooks/useGetBarScales.ts new file mode 100644 index 000000000..f7a7dd977 --- /dev/null +++ b/src/vis/bar/hooks/useGetBarScales.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import ColumnTable from 'arquero/dist/types/table/column-table'; +import { desc, op, table, addFunction } from 'arquero'; +import { useMemo } from 'react'; +import * as d3 from 'd3v7'; +import { getBarData, sortTableBySortType } from '../utils'; +import { SortTypes } from '../interfaces'; +import { EAggregateTypes } from '../../interfaces'; + +export function useGetBarScales( + allColumns: Awaited>, + height: number, + width: number, + margin: { top: number; left: number; bottom: number; right: number }, + categoryFilter: string | null, + isVertical: boolean, + selectedMap: Record, + sortType: SortTypes, + aggregateType: EAggregateTypes, +): { aggregatedTable: ColumnTable; baseTable: ColumnTable; countScale: d3.ScaleLinear; categoryScale: d3.ScaleBand } { + const baseTable = useMemo(() => { + if (allColumns?.catColVals) { + return table({ + category: allColumns.catColVals.resolvedValues.map((val) => val.val), + group: allColumns?.groupColVals?.resolvedValues.map((val) => val.val), + multiples: allColumns?.multiplesColVals?.resolvedValues.map((val) => val.val) || [], + selected: allColumns.catColVals.resolvedValues.map((val) => (selectedMap[val.id] ? 1 : 0)), + aggregateVal: allColumns?.aggregateColVals?.resolvedValues.map((val) => val.val) || [], + id: allColumns.catColVals.resolvedValues.map((val) => val.id), + }); + } + + return null; + }, [allColumns, selectedMap]); + + const aggregateFunc = useMemo(() => { + switch (aggregateType) { + case EAggregateTypes.COUNT: + return (d) => op.count(); + case EAggregateTypes.AVG: + return (d) => op.average(d.aggregateVal); + case EAggregateTypes.MIN: + return (d) => op.min(d.aggregateVal); + case EAggregateTypes.MED: + return (d) => op.median(d.aggregateVal); + case EAggregateTypes.MAX: + return (d) => op.max(d.aggregateVal); + default: + return (d) => op.count(); + } + }, [aggregateType]); + + const aggregatedTable = useMemo(() => { + if (allColumns?.catColVals) { + let myTable = baseTable; + + if (categoryFilter && allColumns?.multiplesColVals) { + myTable = baseTable.params({ categoryFilter }).filter((d) => d.multiples === categoryFilter); + } + + addFunction('aggregateFunc', aggregateFunc, { override: true }); + + return myTable + .groupby('category') + .rollup({ + aggregateVal: aggregateFunc, + count: op.count(), + selectedCount: (d) => op.sum(d.selected), + ids: (d) => op.array_agg(d.id), + }) + .orderby('category'); + } + + return null; + }, [aggregateFunc, allColumns?.catColVals, allColumns?.multiplesColVals, baseTable, categoryFilter]); + + const countScale = useMemo(() => { + if (!aggregatedTable) return null; + return d3 + .scaleLinear() + .range(isVertical ? [height - margin.bottom, margin.top] : [width - margin.right, margin.left]) + .domain([0, +d3.max(aggregatedTable.array('aggregateVal')) + +d3.max(aggregatedTable.array('aggregateVal')) / 25]); + }, [aggregatedTable, height, isVertical, margin, width]); + + const categoryScale = useMemo(() => { + if (!aggregatedTable) return null; + return d3 + .scaleBand() + .range(isVertical ? [width - margin.right, margin.left] : [height - margin.bottom, margin.top]) + .domain(sortTableBySortType(aggregatedTable, sortType).array('category')) + .padding(0.2); + }, [aggregatedTable, height, isVertical, margin.bottom, margin.left, margin.right, margin.top, sortType, width]); + + return { aggregatedTable, baseTable, countScale, categoryScale }; +} diff --git a/src/vis/bar/hooks/useGetGroupedBarScales.ts b/src/vis/bar/hooks/useGetGroupedBarScales.ts new file mode 100644 index 000000000..7117cd7a4 --- /dev/null +++ b/src/vis/bar/hooks/useGetGroupedBarScales.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { escape, op } from 'arquero'; +import ColumnTable from 'arquero/dist/types/table/column-table'; +import * as d3 from 'd3v7'; +import { useMemo } from 'react'; +import { EAggregateTypes, EColumnTypes } from '../../interfaces'; +import { binByAggregateType, getBarData, groupByAggregateType, rollupByAggregateType } from '../utils'; +import { EBarGroupingType, SortTypes } from '../interfaces'; +import { useGetBarScales } from './useGetBarScales'; + +export function useGetGroupedBarScales( + allColumns: Awaited>, + height: number, + width: number, + margin: { top: number; left: number; bottom: number; right: number }, + categoryFilter: string | null, + isVertical: boolean, + selectedMap: Record, + groupType: EBarGroupingType, + sortType: SortTypes, + aggregateType: EAggregateTypes, +): { + aggregatedTable: ColumnTable; + countScale: d3.ScaleLinear; + categoryScale: d3.ScaleBand; + groupedTable: ColumnTable; + groupColorScale: d3.ScaleOrdinal; + groupScale: d3.ScaleBand; +} { + const { aggregatedTable, categoryScale, countScale, baseTable } = useGetBarScales( + allColumns, + height, + width, + margin, + categoryFilter, + isVertical, + selectedMap, + sortType, + aggregateType, + ); + + const groupedTable = useMemo(() => { + if (!allColumns) return null; + + if (allColumns.groupColVals) { + let filteredTable = baseTable; + + if (categoryFilter && allColumns.multiplesColVals) { + filteredTable = baseTable.filter(escape((d) => d.multiples === categoryFilter)); + } + return allColumns.groupColVals.type === EColumnTypes.NUMERICAL + ? binByAggregateType(filteredTable, aggregateType) + : groupByAggregateType(filteredTable, aggregateType); + } + + return null; + }, [aggregateType, allColumns, baseTable, categoryFilter]); + + const groupColorScale = useMemo(() => { + if (!groupedTable) return null; + + const colorScale = ['#337ab7', '#ec6836', '#75c4c2', '#e9d36c', '#24b466', '#e891ae', '#db933c', '#b08aa6', '#8a6044', '#7b7b7b']; + let i = -1; + + const newGroup = groupedTable.ungroup().groupby('group').count(); + const categoricalColors = allColumns.groupColVals.color + ? newGroup + .array('group') + .sort() + .map((value) => { + i += 1; + return allColumns.groupColVals.color[value] || colorScale[i % colorScale.length]; + }) + : colorScale; + + const domain = newGroup.array('group').sort(); + const range = + allColumns.groupColVals.type === EColumnTypes.NUMERICAL + ? d3.schemeBlues[newGroup.array('group').length > 3 ? newGroup.array('group').length : 3] + : categoricalColors; + + return d3.scaleOrdinal().domain(domain).range(range); + }, [groupedTable, allColumns]); + + const groupScale = useMemo(() => { + if (!groupedTable) return null; + const newGroup = groupedTable.ungroup().groupby('category', 'group').count(); + + return d3.scaleBand().range([0, categoryScale.bandwidth()]).domain(newGroup.array('group').sort()).padding(0.1); + }, [categoryScale, groupedTable]); + + const newCountScale = useMemo(() => { + if (!allColumns) return null; + + // No multiples, only group + if (!allColumns.multiplesColVals) { + // No group or group is a stack of count, dont need to change scale + if (!groupedTable || (groupType === EBarGroupingType.STACK && aggregateType === EAggregateTypes.COUNT)) { + return countScale; + } + + // Group is a stack of something other than count, change max. + if (groupType === EBarGroupingType.STACK) { + const max = +d3.max( + groupedTable + .groupby('category') + .rollup({ sum: (d) => op.sum(d.aggregateVal) }) + .array('sum'), + ); + return countScale.copy().domain([0, max + max / 25]); + } + + // Group is not stacked, change max. + const max = +d3.max(groupedTable.array('aggregateVal')); + return countScale.copy().domain([0, max + max / 25]); + } + + // Multiples only, or multiples and stacked. + if (!groupedTable || (groupType === EBarGroupingType.STACK && aggregateType === EAggregateTypes.COUNT)) { + const max = +d3.max(rollupByAggregateType(baseTable.groupby('category', 'multiples'), aggregateType).array('aggregateVal')); + return countScale.copy().domain([0, max + max / 25]); + } + + // Multiples + stacking with something other than count. Tricky one. Change max + if (groupType === EBarGroupingType.STACK) { + const max = +d3.max( + rollupByAggregateType(baseTable.groupby('category', 'group', 'multiples'), aggregateType) + .groupby('category', 'multiples') + .rollup({ sum: (d) => op.sum(d.aggregateVal) }) + .array('sum'), + ); + return countScale.copy().domain([0, max + max / 25]); + } + + // Multiples + grouped but not stacked. Change max. + const max = +d3.max(rollupByAggregateType(baseTable.groupby('group', 'category', 'multiples'), aggregateType).array('aggregateVal')); + + const tempScale = countScale.copy().domain([0, max + max / 25]); + + return tempScale; + }, [aggregateType, allColumns, baseTable, countScale, groupType, groupedTable]); + + return { + aggregatedTable, + countScale: newCountScale, + categoryScale, + groupColorScale, + groupScale, + groupedTable, + }; +} diff --git a/src/vis/bar/index.ts b/src/vis/bar/index.ts deleted file mode 100644 index 92ef13aea..000000000 --- a/src/vis/bar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './BarVis'; -export * from './utils'; diff --git a/src/vis/bar/interfaces.ts b/src/vis/bar/interfaces.ts new file mode 100644 index 000000000..6f2a1f42b --- /dev/null +++ b/src/vis/bar/interfaces.ts @@ -0,0 +1,53 @@ +import { BaseVisConfig, ColumnInfo, EAggregateTypes, ESupportedPlotlyVis } from '../interfaces'; + +export enum SortTypes { + NONE = 'NONE', + CAT_ASC = 'CAT_ASC', + CAT_DESC = 'CAT_DESC', + COUNT_ASC = 'COUNT_ASC', + COUNT_DESC = 'COUNT_DESC', +} + +export enum EBarGroupingType { + STACK = 'Stacked', + GROUP = 'Grouped', +} + +export enum EBarDisplayType { + ABSOLUTE = 'Absolute', + NORMALIZED = 'Normalized', +} +export enum EBarDirection { + VERTICAL = 'Vertical', + HORIZONTAL = 'Horizontal', +} + +export interface IBarConfig extends BaseVisConfig { + type: ESupportedPlotlyVis.BAR; + multiples: ColumnInfo | null; + group: ColumnInfo | null; + direction: EBarDirection; + display: EBarDisplayType; + groupType: EBarGroupingType; + numColumnsSelected: ColumnInfo[]; + catColumnSelected: ColumnInfo; + aggregateType: EAggregateTypes; + aggregateColumn: ColumnInfo | null; +} + +export const defaultConfig: IBarConfig = { + type: ESupportedPlotlyVis.BAR, + numColumnsSelected: [], + catColumnSelected: null, + group: null, + groupType: EBarGroupingType.STACK, + multiples: null, + display: EBarDisplayType.ABSOLUTE, + direction: EBarDirection.HORIZONTAL, + aggregateColumn: null, + aggregateType: EAggregateTypes.COUNT, +}; + +export function isBarConfig(s: BaseVisConfig): s is IBarConfig { + return s.type === ESupportedPlotlyVis.BAR; +} diff --git a/src/vis/bar/utils.ts b/src/vis/bar/utils.ts index e862ea4a2..1797eafd6 100644 --- a/src/vis/bar/utils.ts +++ b/src/vis/bar/utils.ts @@ -1,53 +1,11 @@ +import { bin, desc, op } from 'arquero'; +import ColumnTable from 'arquero/dist/types/table/column-table'; import merge from 'lodash/merge'; -import sum from 'lodash/sum'; -import mean from 'lodash/mean'; -import min from 'lodash/min'; -import max from 'lodash/max'; -import { median } from 'd3v7'; -import { i18n } from '../../i18n'; -import { - PlotlyInfo, - PlotlyData, - VisCategoricalColumn, - EColumnTypes, - ESupportedPlotlyVis, - IVisConfig, - Scales, - VisColumn, - VisCategoricalValue, - IBarConfig, - EBarGroupingType, - EBarDisplayType, - EBarDirection, - EAggregateTypes, - VisNumericalColumn, - VisNumericalValue, -} from '../interfaces'; -import { columnNameWithDescription, resolveSingleColumn, truncateText } from '../general/layoutUtils'; -import { getCol } from '../sidebar'; +import { resolveSingleColumn } from '../general/layoutUtils'; +import { ColumnInfo, EAggregateTypes, EColumnTypes, VisCategoricalValue, VisColumn, VisNumericalValue } from '../interfaces'; +import { IBarConfig, defaultConfig, SortTypes } from './interfaces'; -export function isBar(s: IVisConfig): s is IBarConfig { - return s.type === ESupportedPlotlyVis.BAR; -} - -const UNSELECTED_OPACITY = '0.2'; -const defaultConfig: IBarConfig = { - type: ESupportedPlotlyVis.BAR, - numColumnsSelected: [], - catColumnSelected: null, - group: null, - groupType: EBarGroupingType.STACK, - multiples: null, - display: EBarDisplayType.ABSOLUTE, - direction: EBarDirection.HORIZONTAL, - aggregateColumn: null, - aggregateType: EAggregateTypes.COUNT, -}; - -const TICK_LABEL_LENGTH = 8; -const DEFAULT_GRAY = '#878E95'; - -export function barMergeDefaultConfig(columns: VisColumn[], config: IBarConfig): IVisConfig { +export function barMergeDefaultConfig(columns: VisColumn[], config: IBarConfig): IBarConfig { const merged = merge({}, defaultConfig, config); const catCols = columns.filter((c) => c.type === EColumnTypes.CATEGORICAL); @@ -64,465 +22,186 @@ export function barMergeDefaultConfig(columns: VisColumn[], config: IBarConfig): return merged; } -function createAxisLabel(aggregateType: EAggregateTypes, aggregateColumn: VisColumn) { - return aggregateType === EAggregateTypes.COUNT ? aggregateType : `${aggregateType} of ${columnNameWithDescription(aggregateColumn.info)}`; -} - -/** - * This function finds the faceted values of a given categorical column based on an aggregation type. - * If isTotal is true, it will sum all of the categorical values, used for normalizing the bar charts. - * Otherwise, it will return an array of values corresponding to the categoricalOptions argument - * @param aggregateType Enum for aggregate type - * @param catColValues Categorical Column which we are visualizing - * @param aggColValues Numerical Column which we are using for our aggregation type, unless count is the aggregation type. - * @param _categoricalOptions Optional list of categorical options to find. If not provided, will use a set from the catColValues parameter - * @param isTotal Boolean for deciding whether or not to summarize the results of the list. Also cleans NaN and undefined values to 0 - * @returns A list of numbers if isTotal is false, a single number if true. - */ -function getAggregateValues( - aggregateType: EAggregateTypes, - catColValues: VisCategoricalValue[], - aggColValues: VisNumericalValue[], - _categoricalOptions?: string[], - isTotal?: boolean, -) { - const categoricalOptions = _categoricalOptions || [...new Set(catColValues.map((v) => v.val))]; - - const categoricalMap = {}; - - catColValues.forEach((val) => { - categoricalMap[val.id] = val.val; - }); - - function aggregate(aggFunc) { - const aggValues = categoricalOptions.map( - (curr) => aggFunc((aggColValues as VisNumericalValue[]).filter((c) => categoricalMap[c.id] === curr && !Number.isNaN(c.val)).map((c) => c.val)) || 0, - ); - - return isTotal ? sum(aggValues) : aggValues; +// Helper function for the bar chart which sorts the data depending on the sort type. +export function sortTableBySortType(tempTable: ColumnTable, sortType: SortTypes) { + switch (sortType) { + case SortTypes.CAT_ASC: + return tempTable.orderby('category'); + case SortTypes.CAT_DESC: + return tempTable.orderby(desc('category')); + case SortTypes.COUNT_ASC: + return tempTable.orderby('count'); + case SortTypes.COUNT_DESC: + return tempTable.orderby(desc('count')); + default: + return tempTable; } +} +// Helper function for the bar chart which bins the data depending on the aggregate type. Used for numerical column grouping +export function binByAggregateType(tempTable: ColumnTable, aggregateType: EAggregateTypes) { switch (aggregateType) { + case EAggregateTypes.COUNT: + return tempTable + .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) }) + .rollup({ aggregateVal: () => op.count(), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) }) + .orderby('group') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); case EAggregateTypes.AVG: - return aggregate(mean); - + return tempTable + .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) }) + .rollup({ + aggregateVal: (d) => op.average(d.aggregateVal), + count: op.count(), + selectedCount: (d) => op.sum(d.selected), + ids: (d) => op.array_agg(d.id), + }) + .orderby('group') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); case EAggregateTypes.MIN: - return aggregate(min); - - case EAggregateTypes.MAX: - return aggregate(max); - + return tempTable + .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) }) + .rollup({ aggregateVal: (d) => op.min(d.aggregateVal), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) }) + .orderby('group') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); case EAggregateTypes.MED: - return aggregate(median); - - case EAggregateTypes.COUNT: { - const countValues = categoricalOptions.map((curr) => (catColValues as VisCategoricalValue[]).filter((c) => c.val === curr).length); - return isTotal ? sum(countValues) : countValues; - } + return tempTable + .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) }) + .rollup({ + aggregateVal: (d) => op.median(d.aggregateVal), + count: op.count(), + selectedCount: (d) => op.sum(d.selected), + ids: (d) => op.array_agg(d.id), + }) + .orderby('group') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); + case EAggregateTypes.MAX: + return tempTable + .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) }) + .rollup({ aggregateVal: (d) => op.max(d.aggregateVal), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) }) + .orderby('group') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); default: - throw new Error('Unknown aggregation type'); + return null; } } - -async function setPlotsWithGroupsAndMultiples( - columns: VisColumn[], - catCol: VisCategoricalColumn, - aggregateType: EAggregateTypes, - aggregateColumn: VisNumericalColumn, - config: IBarConfig, - plots: PlotlyData[], - scales: Scales, - plotCounter: number, -): Promise { - let plotCounterEdit = plotCounter; - const catColValues = await resolveSingleColumn(catCol); - const aggColValues = await resolveSingleColumn(aggregateColumn); - - const vertFlag = config.direction === EBarDirection.VERTICAL; - const normalizedFlag = config.display === EBarDisplayType.NORMALIZED; - const currGroupColumn = await resolveSingleColumn(getCol(columns, config.group)); - const currMultiplesColumn = await resolveSingleColumn(getCol(columns, config.multiples)); - - const uniqueGroupVals: string[] = [...new Set(currGroupColumn.resolvedValues.map((v) => v.val))] as string[]; - const uniqueMultiplesVals: string[] = [...new Set(currMultiplesColumn.resolvedValues.map((v) => v.val))] as string[]; - - const uniqueColVals = [...new Set(catColValues.resolvedValues.map((v) => v.val as string))] as string[]; - - uniqueMultiplesVals.forEach((uniqueMultiples) => { - const allMultiplesObjsIds = new Set( - (currMultiplesColumn.resolvedValues as VisCategoricalValue[]).filter((c) => c.val === uniqueMultiples).map((c) => c.id), - ); - - uniqueGroupVals.forEach((uniqueGroup) => { - const allGroupObjsIds = new Set((currGroupColumn.resolvedValues as VisCategoricalValue[]).filter((c) => c.val === uniqueGroup).map((c) => c.id)); - - const aggregateVals = uniqueColVals - .map((v) => { - const allObjs = (catColValues.resolvedValues as VisCategoricalValue[]).filter((c) => c.val === v); - const allObjsIds = new Set(allObjs.map((o) => o.id)); - - const joinedObjs = allObjs.filter((c) => allGroupObjsIds.has(c.id) && allMultiplesObjsIds.has(c.id)); - - const aggregateValues = getAggregateValues(aggregateType, joinedObjs, aggColValues?.resolvedValues as VisNumericalValue[]); - - const ungroupedAggregateValues = getAggregateValues( - aggregateType, - currMultiplesColumn.resolvedValues.filter((val) => allObjsIds.has(val.id)) as VisCategoricalValue[], - aggColValues?.resolvedValues as VisNumericalValue[], - uniqueMultiplesVals, - ); - return joinedObjs.length === 0 ? [0] : normalizedFlag ? (aggregateValues[0] / (ungroupedAggregateValues as number)) * 100 : aggregateValues; +// Helper function for the bar chart which aggregates the data based on the aggregate type. +// Mostly just code duplication with the different aggregate types. +export function groupByAggregateType(tempTable: ColumnTable, aggregateType: EAggregateTypes) { + switch (aggregateType) { + case EAggregateTypes.COUNT: + return tempTable + .groupby('category', 'group') + .rollup({ aggregateVal: () => op.count(), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) }) + .orderby('category') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); + case EAggregateTypes.AVG: + return tempTable + .groupby('category', 'group') + .rollup({ + aggregateVal: (d) => op.average(d.aggregateVal), + count: op.count(), + selectedCount: (d) => op.sum(d.selected), + ids: (d) => op.array_agg(d.id), }) - .flat(); - - const plotAggregateAxisName = createAxisLabel(aggregateType, aggregateColumn); - - let valIdArr: string[][] = uniqueColVals.map((val) => []); - - catColValues.resolvedValues.forEach((row) => valIdArr[uniqueColVals.indexOf(row.val as string)].push(row.id)); - - // stores the actual points of each bar/section of bar in the custom data. - valIdArr = valIdArr.map((arr) => arr.filter((val) => allGroupObjsIds.has(val) && allMultiplesObjsIds.has(val))); - - plots.push({ - data: { - x: vertFlag ? uniqueColVals : aggregateVals, - y: !vertFlag ? uniqueColVals : aggregateVals, - text: uniqueColVals, - ids: uniqueColVals.map((colVal) => `${colVal}, ${uniqueMultiples}, ${uniqueGroup}`), - customdata: valIdArr, - - textposition: 'none', - hoverinfo: vertFlag ? 'y+text' : 'x+text', - orientation: vertFlag ? 'v' : 'h', - xaxis: plotCounterEdit === 1 ? 'x' : `x${plotCounterEdit}`, - yaxis: plotCounterEdit === 1 ? 'y' : `y${plotCounterEdit}`, - showlegend: plotCounterEdit === 1, - type: 'bar', - name: uniqueGroup, - marker: { - color: currGroupColumn.color ? currGroupColumn.color[uniqueGroup] || DEFAULT_GRAY : scales.color(uniqueGroup), - }, - // @ts-ignore - selected: { - marker: { - opacity: '1', - }, - }, - unselected: { - marker: { - opacity: UNSELECTED_OPACITY, - }, - }, - }, - title: uniqueMultiples, - xLabel: vertFlag ? columnNameWithDescription(catColValues.info) : normalizedFlag ? 'Percent of Total' : plotAggregateAxisName, - yLabel: vertFlag ? (normalizedFlag ? 'Percent of Total' : plotAggregateAxisName) : columnNameWithDescription(catColValues.info), - xTicks: vertFlag ? uniqueColVals : null, - xTickLabels: vertFlag ? uniqueColVals.map((v) => truncateText(v, TICK_LABEL_LENGTH)) : null, - yTicks: !vertFlag ? uniqueColVals : null, - yTickLabels: !vertFlag ? uniqueColVals.map((v) => truncateText(v, TICK_LABEL_LENGTH)) : null, - }); - }); - plotCounterEdit += 1; - }); - - return plotCounterEdit; -} - -async function setPlotsWithGroups( - columns: VisColumn[], - catCol: VisCategoricalColumn, - aggregateType: EAggregateTypes, - aggregateColumn: VisNumericalColumn, - config: IBarConfig, - plots: PlotlyData[], - scales: Scales, - plotCounter: number, -): Promise { - const catColValues = await resolveSingleColumn(catCol); - const aggColValues = await resolveSingleColumn(aggregateColumn); - - const vertFlag = config.direction === EBarDirection.VERTICAL; - const normalizedFlag = config.display === EBarDisplayType.NORMALIZED; - const groupColumn = await resolveSingleColumn(getCol(columns, config.group)); - - const uniqueGroupVals: string[] = [...new Set(groupColumn.resolvedValues.map((v) => v.val))] as string[]; - const uniqueColVals: string[] = [...new Set(catColValues.resolvedValues.map((v) => v.val))] as string[]; - - uniqueGroupVals.forEach((uniqueVal) => { - const allGroupObjsIds = new Set((groupColumn.resolvedValues as VisCategoricalValue[]).filter((c) => c.val === uniqueVal).map((c) => c.id)); - const finalAggregateValues = uniqueColVals - .map((v) => { - const allObjs = (catColValues.resolvedValues as VisCategoricalValue[]).filter((c) => c.val === v); - const allObjsIds = new Set(allObjs.map((o) => o.id)); - - const joinedObjs = allObjs.filter((allVal) => allGroupObjsIds.has(allVal.id)); - - const aggregateValues = getAggregateValues(aggregateType, joinedObjs, aggColValues?.resolvedValues as VisNumericalValue[]); - const ungroupedAggregateValues = getAggregateValues( - aggregateType, - groupColumn.resolvedValues.filter((val) => allObjsIds.has(val.id)) as VisCategoricalValue[], - aggColValues?.resolvedValues as VisNumericalValue[], - uniqueGroupVals, - true, - ); - - return joinedObjs.length === 0 ? [0] : normalizedFlag ? (aggregateValues[0] / (ungroupedAggregateValues as number)) * 100 : aggregateValues; - }) - .flat(); - - const plotAggregateAxisName = createAxisLabel(aggregateType, aggregateColumn); - - let valIdArr: string[][] = uniqueColVals.map((val) => []); - - catColValues.resolvedValues.forEach((row) => valIdArr[uniqueColVals.indexOf(row.val as string)].push(row.id)); - - // stores the actual points of each bar/section of bar in the custom data. - valIdArr = valIdArr.map((arr) => arr.filter((val) => allGroupObjsIds.has(val))); - - plots.push({ - data: { - x: vertFlag ? uniqueColVals : finalAggregateValues, - y: !vertFlag ? uniqueColVals : finalAggregateValues, - text: uniqueColVals, - ids: uniqueColVals.map((colVal) => `${colVal}, ${uniqueVal}`), - customdata: valIdArr, - - textposition: 'none', - hoverinfo: vertFlag ? 'y+text' : 'x+text', - orientation: vertFlag ? 'v' : 'h', - xaxis: plotCounter === 1 ? 'x' : `x${plotCounter}`, - yaxis: plotCounter === 1 ? 'y' : `y${plotCounter}`, - showlegend: plotCounter === 1, - type: 'bar', - name: uniqueVal, - marker: { - color: groupColumn.color ? groupColumn.color[uniqueVal] || DEFAULT_GRAY : scales.color(uniqueVal), - }, - // @ts-ignore - selected: { - marker: { - opacity: '1', - }, - }, - unselected: { - marker: { - opacity: UNSELECTED_OPACITY, - }, - }, - }, - xLabel: vertFlag ? columnNameWithDescription(catColValues.info) : normalizedFlag ? 'Percent of Total' : plotAggregateAxisName, - yLabel: vertFlag ? (normalizedFlag ? 'Percent of Total' : plotAggregateAxisName) : columnNameWithDescription(catColValues.info), - xTicks: vertFlag ? uniqueColVals : null, - xTickLabels: vertFlag ? uniqueColVals.map((v) => truncateText(v, TICK_LABEL_LENGTH)) : null, - yTicks: !vertFlag ? uniqueColVals : null, - yTickLabels: !vertFlag ? uniqueColVals.map((v) => truncateText(v, TICK_LABEL_LENGTH)) : null, - }); - }); + .orderby('category') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); + case EAggregateTypes.MIN: + return tempTable + .groupby('category', 'group') + .rollup({ aggregateVal: (d) => op.min(d.aggregateVal), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) }) + .orderby('category') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); + case EAggregateTypes.MED: + return tempTable + .groupby('category', 'group') + .rollup({ + aggregateVal: (d) => op.median(d.aggregateVal), + count: op.count(), + selectedCount: (d) => op.sum(d.selected), + ids: (d) => op.array_agg(d.id), + }) + .orderby('category') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); - return plotCounter; + case EAggregateTypes.MAX: + return tempTable + .groupby('category', 'group') + .rollup({ aggregateVal: (d) => op.max(d.aggregateVal), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) }) + .orderby('category') + .groupby('category') + .derive({ categoryCount: (d) => op.sum(d.count) }); + default: + return null; + } } -async function setPlotsWithMultiples( - columns: VisColumn[], - catCol: VisCategoricalColumn, - aggregateType: EAggregateTypes, - aggregateColumn: VisNumericalColumn, - config: IBarConfig, - plots: PlotlyData[], - plotCounter: number, -): Promise { - let plotCounterEdit = plotCounter; - const catColValues = await resolveSingleColumn(catCol); - const aggColValues = await resolveSingleColumn(aggregateColumn); - const multiplesColumn = await resolveSingleColumn(getCol(columns, config.multiples)); - - const vertFlag = config.direction === EBarDirection.VERTICAL; - - const uniqueMultiplesVals: string[] = [...new Set((await multiplesColumn).resolvedValues.map((v) => v.val))] as string[]; - const uniqueColVals: string[] = [...new Set(catColValues.resolvedValues.map((v) => v.val))] as string[]; - - uniqueMultiplesVals.forEach((uniqueVal) => { - const allMultiplesObjsIds = new Set((multiplesColumn.resolvedValues as VisCategoricalValue[]).filter((c) => c.val === uniqueVal).map((c) => c.id)); - - const finalAggregateValues = uniqueColVals - .map((v) => { - const allObjs = (catColValues.resolvedValues as VisCategoricalValue[]).filter((c) => c.val === v); - const joinedObjs = allObjs.filter((c) => allMultiplesObjsIds.has(c.id)); - - return joinedObjs.length === 0 ? [0] : getAggregateValues(aggregateType, joinedObjs, aggColValues?.resolvedValues as VisNumericalValue[]); - }) - .flat(); - const plotAggregateAxisName = createAxisLabel(aggregateType, aggregateColumn); - - let valIdArr: string[][] = uniqueColVals.map((val) => []); - - catColValues.resolvedValues.forEach((row) => valIdArr[uniqueColVals.indexOf(row.val as string)].push(row.id)); +// Helper function for the bar chart which rolls up the data depending on the aggregate type. +// Mostly just code duplication with the different aggregate types. +export function rollupByAggregateType(tempTable: ColumnTable, aggregateType: EAggregateTypes) { + switch (aggregateType) { + case EAggregateTypes.COUNT: + return tempTable.rollup({ aggregateVal: () => op.count() }); + case EAggregateTypes.AVG: + return tempTable.rollup({ aggregateVal: (d) => op.average(d.aggregateVal) }); - // stores the actual points of each bar/section of bar in the custom data. - valIdArr = valIdArr.map((arr) => arr.filter((val) => allMultiplesObjsIds.has(val))); + case EAggregateTypes.MIN: + return tempTable.rollup({ aggregateVal: (d) => op.min(d.aggregateVal) }); - plots.push({ - data: { - x: vertFlag ? uniqueColVals : finalAggregateValues, - y: !vertFlag ? uniqueColVals : finalAggregateValues, - ids: uniqueColVals.map((colVal) => `${colVal}, ${uniqueVal}`), - text: uniqueColVals, - textposition: 'none', - customdata: valIdArr, - hoverinfo: vertFlag ? 'y+text' : 'x+text', - orientation: vertFlag ? 'v' : 'h', - xaxis: plotCounterEdit === 1 ? 'x' : `x${plotCounterEdit}`, - yaxis: plotCounterEdit === 1 ? 'y' : `y${plotCounterEdit}`, - showlegend: false, - type: 'bar', - name: uniqueVal, - marker: { - color: DEFAULT_GRAY, - }, - // @ts-ignore - selected: { - marker: { - opacity: '1', - }, - }, - unselected: { - marker: { - opacity: UNSELECTED_OPACITY, - }, - }, - }, - xLabel: vertFlag ? columnNameWithDescription(catColValues.info) : plotAggregateAxisName, - yLabel: vertFlag ? plotAggregateAxisName : columnNameWithDescription(catColValues.info), - xTicks: vertFlag ? uniqueColVals : null, - xTickLabels: vertFlag ? uniqueColVals.map((v) => truncateText(v, TICK_LABEL_LENGTH)) : null, - yTicks: !vertFlag ? uniqueColVals : null, - yTickLabels: !vertFlag ? uniqueColVals.map((v) => truncateText(v, TICK_LABEL_LENGTH)) : null, - title: uniqueVal, - }); - plotCounterEdit += 1; - }); + case EAggregateTypes.MED: + return tempTable.rollup({ aggregateVal: (d) => op.median(d.aggregateVal) }); + case EAggregateTypes.MAX: + return tempTable.rollup({ aggregateVal: (d) => op.max(d.aggregateVal) }); - return plotCounterEdit; + default: + return null; + } } -async function setPlotsBasic( +export async function getBarData( columns: VisColumn[], - aggregateType: EAggregateTypes, - aggregateColumn: VisNumericalColumn | null, - catCol: VisCategoricalColumn, - config: IBarConfig, - plots: PlotlyData[], - scales: Scales, - plotCounter: number, -): Promise { - let plotCounterEdit = plotCounter; - const catColValues = await resolveSingleColumn(catCol); - const aggColValues = await resolveSingleColumn(aggregateColumn); - - const vertFlag = config.direction === EBarDirection.VERTICAL; - - const aggValues: any[] = getAggregateValues( - aggregateType, - catColValues.resolvedValues as VisCategoricalValue[], - aggColValues?.resolvedValues as VisNumericalValue[], - ) as any[]; - - const valArr = [...new Set(catColValues.resolvedValues.map((v) => v.val as string))]; - - // stores the actual points of each bar/section of bar in the custom data. - const valIdArr: string[][] = valArr.map((val) => []); - - catColValues.resolvedValues.forEach((row) => valIdArr[valArr.indexOf(row.val as string)].push(row.id)); - - const plotAggregateAxisName = createAxisLabel(aggregateType, aggregateColumn); - - plots.push({ - data: { - type: 'bar', - x: vertFlag ? valArr : aggValues, - y: !vertFlag ? valArr : aggValues, - text: valArr, - textposition: 'none', - hoverinfo: vertFlag ? 'y+text' : 'x+text', - ids: valArr, - marker: { - color: DEFAULT_GRAY, - }, - // @ts-ignore - selected: { - marker: { - opacity: '1', - }, - }, - unselected: { - marker: { - opacity: UNSELECTED_OPACITY, - }, - }, - customdata: valIdArr, - orientation: vertFlag ? 'v' : 'h', - xaxis: plotCounter === 1 ? 'x' : `x${plotCounter}`, - yaxis: plotCounter === 1 ? 'y' : `y${plotCounter}`, - name: columnNameWithDescription(catColValues.info), - showlegend: false, - }, - xLabel: vertFlag ? columnNameWithDescription(catColValues.info) : plotAggregateAxisName, - yLabel: vertFlag ? plotAggregateAxisName : columnNameWithDescription(catColValues.info), - xTicks: vertFlag ? valArr : null, - xTickLabels: vertFlag ? valArr.map((v) => truncateText(v, TICK_LABEL_LENGTH)) : null, - yTicks: !vertFlag ? valArr : null, - yTickLabels: !vertFlag ? valArr.map((v) => truncateText(v, TICK_LABEL_LENGTH)) : null, - }); - plotCounterEdit += 1; - - return plotCounterEdit; -} - -export async function createBarTraces(columns: VisColumn[], config: IBarConfig, scales: Scales): Promise { - let plotCounter = 1; - - if (!config.catColumnSelected) { - return { - plots: [], - legendPlots: [], - rows: 0, - cols: 0, - errorMessage: i18n.t('visyn:vis.barError'), - errorMessageHeader: i18n.t('visyn:vis.errorHeader'), - }; - } - - const plots: PlotlyData[] = []; - - const catCol: VisCategoricalColumn = columns.find((c) => c.info.id === config.catColumnSelected.id) as VisCategoricalColumn; - const aggregateColumn: VisNumericalColumn = config.aggregateColumn - ? (columns.find((c) => c.info.id === config.aggregateColumn.id) as VisNumericalColumn) - : null; - - if (catCol) { - if (config.group && config.multiples) { - plotCounter = await setPlotsWithGroupsAndMultiples(columns, catCol, config.aggregateType, aggregateColumn, config, plots, scales, plotCounter); - } else if (config.group) { - plotCounter = await setPlotsWithGroups(columns, catCol, config.aggregateType, aggregateColumn, config, plots, scales, plotCounter); - } else if (config.multiples) { - plotCounter = await setPlotsWithMultiples(columns, catCol, config.aggregateType, aggregateColumn, config, plots, plotCounter); - } else { - plotCounter = await setPlotsBasic(columns, config.aggregateType, aggregateColumn, catCol, config, plots, scales, plotCounter); - } - } + catColumn: ColumnInfo, + groupColumn: ColumnInfo | null, + multiplesColumn: ColumnInfo | null, + aggregateColumn: ColumnInfo | null, +): Promise<{ + catColVals: { + resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; + type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL; + info: ColumnInfo; + }; + groupColVals: { + resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; + type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL; + info: ColumnInfo; + color?: Record; + }; + multiplesColVals: { + resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; + type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL; + info: ColumnInfo; + }; + aggregateColVals: { + resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; + type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL; + info: ColumnInfo; + }; +}> { + const catColVals = await resolveSingleColumn(columns.find((col) => col.info.id === catColumn.id)); - const rows = Math.ceil(Math.sqrt(plotCounter - 1)); - const cols = Math.ceil((plotCounter - 1) / rows); + const groupColVals = await resolveSingleColumn(groupColumn ? columns.find((col) => col.info.id === groupColumn.id) : null); + const multiplesColVals = await resolveSingleColumn(multiplesColumn ? columns.find((col) => col.info.id === multiplesColumn.id) : null); + const aggregateColVals = await resolveSingleColumn(aggregateColumn ? columns.find((col) => col.info.id === aggregateColumn.id) : null); - return { - plots, - legendPlots: [], - rows, - cols, - errorMessage: i18n.t('visyn:vis.barError'), - errorMessageHeader: i18n.t('visyn:vis.errorHeader'), - }; + return { catColVals, groupColVals, multiplesColVals, aggregateColVals }; } diff --git a/src/vis/correlation/CorrelationMatrix.tsx b/src/vis/correlation/CorrelationMatrix.tsx new file mode 100644 index 000000000..11cbe75a1 --- /dev/null +++ b/src/vis/correlation/CorrelationMatrix.tsx @@ -0,0 +1,190 @@ +import { Box, Center, Group, Loader, Stack, Text } from '@mantine/core'; +import { useResizeObserver } from '@mantine/hooks'; +import * as d3 from 'd3v7'; +import { scaleBand } from 'd3v7'; +import { corrcoeff, spearmancoeff, tukeyhsd } from 'jstat'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { useAsync } from '../../hooks/useAsync'; +import { ColumnInfo, EColumnTypes, EScaleType, VisCategoricalValue, VisColumn, VisNumericalValue } from '../interfaces'; +import { ColorLegendVert } from '../legend/ColorLegendVert'; +import { CorrelationPair, CorrelationPairProps } from './components/CorrelationPair'; +import { ECorrelationType, ICorrelationConfig } from './interfaces'; +import { getCorrelationMatrixData } from './utils'; + +const paddingCircle = { top: 5, right: 5, bottom: 5, left: 5 }; +const CIRCLE_MIN_SIZE = 4; + +const margin = { top: 20, right: 20, bottom: 20, left: 20 }; + +export function CorrelationMatrix({ config, columns }: { config: ICorrelationConfig; columns: VisColumn[] }) { + const { value: dataAll, status } = useAsync(getCorrelationMatrixData, [columns, config.numColumnsSelected]); + const [data, setData] = React.useState<{ resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; type: EColumnTypes; info: ColumnInfo }[]>(null); + + // Set data used for calculation and apply filter if given + React.useEffect(() => { + if (dataAll) { + const cols = []; + dataAll.numericalColumns.forEach((col) => { + cols.push(col); + }); + setData(cols); + } + }, [dataAll]); + + const [ref, { width, height }] = useResizeObserver(); + + const availableSize = useMemo(() => { + return Math.min(width - margin.left - margin.right, height - margin.top - margin.bottom); + }, [height, width]); + + const colorScale = d3 + .scaleSequential( + d3.piecewise( + d3.interpolateRgb.gamma(2.2), + [ + '#003367', + '#16518a', + '#2e72ae', + '#5093cd', + '#77b5ea', + '#aad7fd', + '#F1F3F5', + '#fac7a9', + '#f99761', + '#e06d3b', + '#c2451a', + '#99230d', + '#6f0000', + ].reverse(), + ), + ) + .domain([-1, 1]); + + // Scales + const xScale = React.useMemo(() => { + if (!data) return null; + return scaleBand() + .range([margin.left, availableSize + margin.left]) + .domain(data.map((column) => column.info.id)); + }, [data, availableSize]); + + const yScale = React.useMemo(() => { + if (!data) return null; + return scaleBand() + .range([margin.top, availableSize + margin.top]) + .domain(data.map((column) => column.info.id)); + }, [data, availableSize]); + + const circleSizeScale = React.useMemo(() => { + if (!data) return null; + const maxSize = Math.min(xScale.bandwidth() / 2 - paddingCircle.left, yScale.bandwidth() / 2 - paddingCircle.top); + return config.pScaleType === EScaleType.LINEAR + ? d3.scaleLinear().domain(config.pDomain).range([CIRCLE_MIN_SIZE, maxSize]).clamp(true) + : d3.scaleLog().domain(config.pDomain).range([CIRCLE_MIN_SIZE, maxSize]).clamp(true); + }, [config.pDomain, config.pScaleType, data, xScale, yScale]); + + // Calculate correlation results + const memoizedCorrelationResults = React.useMemo(() => { + if (!data) return null; + + let coefffunc = (x: number[], y: number[]) => null; + if (config.correlationType === ECorrelationType.PEARSON) { + coefffunc = corrcoeff; + } else if (config.correlationType === ECorrelationType.SPEARMAN) { + coefffunc = spearmancoeff; + } + + const cols = data; + const correlationResults = [] as CorrelationPairProps[]; + + for (let x = 1; x < cols.length; x++) { + for (let y = 0; y < x; y++) { + const correlation = coefffunc( + cols[x].resolvedValues.map((resolved) => resolved.val as number), + cols[y].resolvedValues.map((resolved) => resolved.val as number), + ); + + const pValue = tukeyhsd([ + cols[x].resolvedValues.map((resolved) => resolved.val as number), + cols[y].resolvedValues.map((resolved) => resolved.val as number), + ]); + + const xName = cols[x].info.id; + const yName = cols[y].info.id; + + const value: CorrelationPairProps = { + xi: x, + yi: y, + cxLT: xScale(yName) + xScale.bandwidth() / 2, + cyLT: yScale(xName) + yScale.bandwidth() / 2, + cxUT: xScale(xName) + xScale.bandwidth() / 2, + cyUT: yScale(yName) + yScale.bandwidth() / 2, + correlation, + pValue: pValue[0][1], + xName: cols[x].info.name, + yName: cols[y].info.name, + radius: circleSizeScale(pValue[0][1]), + }; + correlationResults.push(value); + } + } + + return correlationResults; + }, [circleSizeScale, config.correlationType, data, xScale, yScale]); + + // Show labels on diagonal of matrix + const labelsDiagonal = React.useMemo(() => { + if (!data) return null; + return data.map((col) => { + const currentX = xScale(col.info.id); + const currentY = yScale(col.info.id); + return ( + + + +
+ + {col.info.name} + +
+
+
+ ); + }); + }, [data, xScale, yScale]); + + return ( + + {status === 'success' ? ( + + + + + + + + {memoizedCorrelationResults?.map((value) => { + return ( + + ); + })} + {labelsDiagonal} + + + + + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/vis/correlation/CorrelationVis.tsx b/src/vis/correlation/CorrelationVis.tsx new file mode 100644 index 000000000..a1c6cc46a --- /dev/null +++ b/src/vis/correlation/CorrelationVis.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { InvalidCols } from '../general/InvalidCols'; +import { ICommonVisProps } from '../interfaces'; +import { CorrelationMatrix } from './CorrelationMatrix'; +import { ICorrelationConfig } from './interfaces'; + +export function CorrelationVis({ config, columns }: ICommonVisProps) { + return config.numColumnsSelected.length > 1 ? ( + + ) : ( + + ); +} diff --git a/src/vis/correlation/CorrelationVisSidebar.tsx b/src/vis/correlation/CorrelationVisSidebar.tsx new file mode 100644 index 000000000..06f477ab4 --- /dev/null +++ b/src/vis/correlation/CorrelationVisSidebar.tsx @@ -0,0 +1,112 @@ +import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { ActionIcon, Group, Input, NumberInput, SegmentedControl, Text, Tooltip } from '@mantine/core'; +import * as d3 from 'd3v7'; +import * as React from 'react'; +import { ColumnInfo, EScaleType, ICommonVisSideBarProps, VisColumn } from '../interfaces'; +import { NumericalColumnSelect } from '../sidebar/NumericalColumnSelect'; +import { ECorrelationType, ICorrelationConfig } from './interfaces'; + +export function CorrelationVisSidebar({ + config, + columns, + setConfig, + style: { width = '20em', ...style } = {}, +}: { + config: ICorrelationConfig; + + columns: VisColumn[]; + setConfig: (config: ICorrelationConfig) => void; +} & ICommonVisSideBarProps) { + return ( + <> + setConfig({ ...config, numColumnsSelected })} + columns={columns} + currentSelected={config.numColumnsSelected || []} + /> + + + setConfig({ ...config, correlationType: v as ECorrelationType })} + /> + + + + setConfig({ ...config, pScaleType: v as EScaleType })} + /> + + { + return d3.format('.3~g')(+value); + }} + onChange={(val) => setConfig({ ...config, pDomain: [+val, config.pDomain[1]] })} + label={ + + Maximum p-value + + Sets the maximum p-value for the size scale. Any p-value at or above this value will have the smallest possible circle + + } + > + + + + + + } + value={config.pDomain[0]} + /> + { + return d3.format('.3~g')(+value); + }} + onChange={(val) => setConfig({ ...config, pDomain: [config.pDomain[0], +val] })} + label={ + + Minimum p-value + + Sets the minimum p-value for the size scale. Any p-value at or below this value will have the largest possible circle + + } + > + + + + + + } + value={config.pDomain[1]} + /> + + ); +} diff --git a/src/vis/correlation/components/CorrelationPair.tsx b/src/vis/correlation/components/CorrelationPair.tsx new file mode 100644 index 000000000..f10a4bf6a --- /dev/null +++ b/src/vis/correlation/components/CorrelationPair.tsx @@ -0,0 +1,118 @@ +import { Center, Stack, Text, Tooltip, useMantineTheme } from '@mantine/core'; +import * as d3 from 'd3v7'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { ICorrelationConfig } from '../interfaces'; + +const marginRect = { top: 0, right: 0, bottom: 0, left: 0 }; + +export interface CorrelationPairProps { + xi: number; + yi: number; + cxLT: number; + cyLT: number; + cxUT: number; + cyUT: number; + correlation: number; + pValue: number; + xName: string; + yName: string; + radius: number; +} + +export function CorrelationPair({ + value, + fill, + boundingRect, + config, +}: { + value: CorrelationPairProps; + fill: string; + boundingRect: { width: number; height: number }; + config: ICorrelationConfig; +}) { + const [isHover, setIsHover] = React.useState(false); + const theme = useMantineTheme(); + const hoverColor = theme.colors.gray[1]; + + const format = useMemo(() => { + return d3.format('.3g'); + }, []); + + const correlationFormat = useMemo(() => { + return d3.format('.3g'); + }, []); + + const topRect = useMemo(() => { + return ( + + + + + ); + }, [boundingRect.height, boundingRect.width, fill, hoverColor, isHover, value]); + + const bottomRect = useMemo(() => { + return ( + + + +
+ + + {`r: ${correlationFormat(value.correlation)}`} + + + {`p: ${format(value.pValue)}`} + + +
+
+
+ ); + }, [boundingRect.height, boundingRect.width, correlationFormat, format, hoverColor, isHover, value.correlation, value.cxLT, value.cyLT, value.pValue]); + + const label = useMemo(() => { + return ( + + {`${value.xName} / ${value.yName}`} + Correlation: {correlationFormat(value.correlation)} + P-value: {format(value.pValue)} + + ); + }, [correlationFormat, format, value]); + + return ( + setIsHover(true)} onMouseLeave={() => setIsHover(false)}> + + + + + ); +} diff --git a/src/vis/correlation/index.ts b/src/vis/correlation/index.ts new file mode 100644 index 000000000..3019af66b --- /dev/null +++ b/src/vis/correlation/index.ts @@ -0,0 +1,2 @@ +export * from './CorrelationMatrix'; +export * from './utils'; diff --git a/src/vis/correlation/interfaces.ts b/src/vis/correlation/interfaces.ts new file mode 100644 index 000000000..b92cb914d --- /dev/null +++ b/src/vis/correlation/interfaces.ts @@ -0,0 +1,18 @@ +import { BaseVisConfig, ColumnInfo, EScaleType, ESupportedPlotlyVis } from '../interfaces'; + +export interface ICorrelationConfig extends BaseVisConfig { + type: ESupportedPlotlyVis.CORRELATION; + correlationType: ECorrelationType; + numColumnsSelected: ColumnInfo[]; + pScaleType: EScaleType; + pDomain: [number, number]; +} + +export enum ECorrelationType { + PEARSON = 'Pearson', + SPEARMAN = 'Spearman', +} + +export function isCorrelationConfig(s: BaseVisConfig): s is ICorrelationConfig { + return s.type === ESupportedPlotlyVis.CORRELATION; +} diff --git a/src/vis/correlation/utils.ts b/src/vis/correlation/utils.ts new file mode 100644 index 000000000..5aca9a339 --- /dev/null +++ b/src/vis/correlation/utils.ts @@ -0,0 +1,32 @@ +import merge from 'lodash/merge'; +import { resolveColumnValues } from '../general/layoutUtils'; +import { ColumnInfo, EColumnTypes, EScaleType, ESupportedPlotlyVis, VisCategoricalValue, VisColumn, VisNumericalValue } from '../interfaces'; +import { ECorrelationType, ICorrelationConfig } from './interfaces'; + +const defaultConfig: ICorrelationConfig = { + type: ESupportedPlotlyVis.CORRELATION, + correlationType: ECorrelationType.PEARSON, + numColumnsSelected: [], + pScaleType: EScaleType.LOG, + pDomain: [0.5, 0.01], +}; + +export function correlationMergeDefaultConfig(columns: VisColumn[], config: ICorrelationConfig): ICorrelationConfig { + const merged = merge({}, defaultConfig, config); + return merged; +} + +export async function getCorrelationMatrixData( + columns: VisColumn[], + numericalColumnDescs: ColumnInfo[], +): Promise<{ + numericalColumns: { + resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; + type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL; + info: ColumnInfo; + }[]; +}> { + const numericalColumns = await resolveColumnValues(columns.filter((col) => numericalColumnDescs.find((numCol) => numCol.id === col.info.id))); + + return { numericalColumns }; +} diff --git a/src/vis/general/InvalidCols.tsx b/src/vis/general/InvalidCols.tsx index 30cc12a2e..356a6c11d 100644 --- a/src/vis/general/InvalidCols.tsx +++ b/src/vis/general/InvalidCols.tsx @@ -1,11 +1,11 @@ +import { Alert, Center, Stack, rem } from '@mantine/core'; import * as React from 'react'; -import { Alert, Center, Stack } from '@mantine/core'; export function InvalidCols({ headerMessage, bodyMessage }: { headerMessage: string; bodyMessage: string }) { return (
- + {bodyMessage}
diff --git a/src/vis/heatmap/AnimatedLine.tsx b/src/vis/heatmap/AnimatedLine.tsx new file mode 100644 index 000000000..ca84a230c --- /dev/null +++ b/src/vis/heatmap/AnimatedLine.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { useMemo, useRef } from 'react'; +import { useSpring, animated, easings } from 'react-spring'; + +export function AnimatedLine({ + x1, + x2, + y1, + y2, + order = 1, + setImmediate, +}: { + y2: number; + y1: number; + x2: number; + x1: number; + order?: number; + setImmediate: boolean; +}) { + const myOrder = useRef(order); + + const isImmediate = setImmediate || myOrder.current === order; + const spring = useSpring({ + x1, + y1, + x2, + y2, + config: { duration: 2000, easing: easings.easeInOutSine }, + delay: isImmediate ? 0 : 2000 * order, + immediate: isImmediate, + }); + + React.useEffect(() => { + myOrder.current = order; + }, [order]); + + const line = useMemo(() => { + return ; + }, [spring]); + + return line; +} diff --git a/src/vis/heatmap/AnimatedText.tsx b/src/vis/heatmap/AnimatedText.tsx new file mode 100644 index 000000000..af1e74aa6 --- /dev/null +++ b/src/vis/heatmap/AnimatedText.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { useEffect, useMemo, useRef } from 'react'; +import { useSpring, animated, easings } from 'react-spring'; + +export function AnimatedText({ + x, + y, + children, + order, + width, + height, + bold = false, + setImmediate = true, +}: { + x: number; + y: number; + children; + order?: number; + bold?: boolean; + width: number; + height: number; + setImmediate?: boolean; +}) { + const myOrder = useRef(order); + + const isImmediate = setImmediate || myOrder.current === order; + const spring = useSpring({ + x, + y, + width, + height, + config: { duration: 2000, easing: easings.easeInOutSine }, + delay: isImmediate ? 0 : 2000 * order, + immediate: isImmediate, + }); + + const line = useMemo(() => { + return {children}; + }, [children, spring]); + + useEffect(() => { + myOrder.current = order; + }, [order]); + + return line; +} diff --git a/src/vis/heatmap/Heatmap.tsx b/src/vis/heatmap/Heatmap.tsx new file mode 100644 index 000000000..dafe044b1 --- /dev/null +++ b/src/vis/heatmap/Heatmap.tsx @@ -0,0 +1,321 @@ +import { faArrowDownShortWide, faArrowDownWideShort, faArrowDownAZ, faArrowDownZA } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Box, Container, Group, Stack, Text } from '@mantine/core'; +import { useResizeObserver } from '@mantine/hooks'; +import { desc, op, table } from 'arquero'; +import * as d3 from 'd3v7'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { rollupByAggregateType } from '../bar/utils'; +import { ColumnInfo, EAggregateTypes, EColumnTypes, ENumericalColorScaleType, VisCategoricalValue, VisNumericalValue } from '../interfaces'; +import { ColorLegendVert } from '../legend/ColorLegendVert'; +import { HeatmapRect } from './HeatmapRect'; +import { HeatmapText } from './HeatmapText'; +import { ESortTypes, IHeatmapConfig } from './interfaces'; + +const interRectDistance = 1; + +type CatColumn = { + resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; + type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL; + info: ColumnInfo; +}; + +export function Heatmap({ + column1, + column2, + aggregateColumn, + margin, + config, + selected, + selectionCallback, + setExternalConfig, +}: { + column1: CatColumn; + column2: CatColumn; + aggregateColumn: CatColumn; + margin: { top: number; right: number; bottom: number; left: number }; + config: IHeatmapConfig; + selectionCallback: (ids: string[]) => void; + selected?: { [key: string]: boolean }; + setExternalConfig?: (config: IHeatmapConfig) => void; +}) { + const [ref, { width, height }] = useResizeObserver(); + + const baseTable = useMemo(() => { + if (!column1 || !column2) return null; + + return table({ + xVal: column1.resolvedValues.map(({ val }) => val), + yVal: column2.resolvedValues.map(({ val }) => val), + aggregateVal: aggregateColumn?.resolvedValues.map(({ val }) => val) || [], + id: column1.resolvedValues.map(({ id }) => id), + }); + }, [aggregateColumn?.resolvedValues, column1, column2]); + const aggregatedTable = useMemo(() => { + if (!baseTable) return null; + + let valueTable = rollupByAggregateType(baseTable.groupby('xVal', 'yVal'), config.aggregateType); + + if (config.aggregateType === EAggregateTypes.COUNT) { + valueTable = valueTable.impute({ aggregateVal: () => 0 }, { expand: ['xVal', 'yVal'] }); + } else { + valueTable = valueTable.impute({ aggregateVal: () => null }, { expand: ['xVal', 'yVal'] }); + } + + valueTable = valueTable + .groupby('xVal') + .derive({ colTotal: op.sum('aggregateVal') }) + .groupby('yVal') + .derive({ rowTotal: op.sum('aggregateVal') }); + + // default is ESortTypes.CAT_ASC + let xOrder: string | object; + switch (config.xSortedBy) { + case ESortTypes.VAL_ASC: + xOrder = 'colTotal'; + break; + case ESortTypes.CAT_DESC: + xOrder = desc('xVal'); + break; + case ESortTypes.VAL_DESC: + xOrder = desc('colTotal'); + break; + default: + xOrder = 'xVal'; + break; + } + + // default is ESortTypes.CAT_ASC + let yOrder: string | object; + switch (config.ySortedBy) { + case ESortTypes.VAL_ASC: + yOrder = 'rowTotal'; + break; + case ESortTypes.CAT_DESC: + yOrder = desc('yVal'); + break; + case ESortTypes.VAL_DESC: + yOrder = desc('rowTotal'); + break; + default: + yOrder = 'yVal'; + break; + } + valueTable = valueTable.orderby(xOrder, yOrder); + + return valueTable; + }, [baseTable, config.aggregateType, config.xSortedBy, config.ySortedBy]); + + const { groupedValues, rectHeight, rectWidth, yScale, xScale, colorScale } = React.useMemo(() => { + const groupedVals = aggregatedTable.objects() as { xVal: string; yVal: string; aggregateVal: number; ids: string[] }[]; + + const xSc = d3 + .scaleBand() + .domain(groupedVals.map((gV) => gV.xVal)) + .range([0, width - margin.left - margin.right]); + + const ySc = d3 + .scaleBand() + .domain(groupedVals.map((gV) => gV.yVal)) + .range([0, height - margin.top - margin.bottom]); + + const colorSc = + config?.numColorScaleType === ENumericalColorScaleType.SEQUENTIAL + ? d3 + .scaleSequential( + d3.piecewise( + d3.interpolateRgb.gamma(2.2), + ['#24528d', '#2d67a0', '#3b7bb2', '#4d90c3', '#65a5d3', '#80bae0', '#a0ceeb', '#c6e1f2', '#f1f3f5'].reverse(), + ), + ) + .domain( + config.aggregateType === EAggregateTypes.COUNT + ? [0, d3.max(groupedVals, (d) => d.aggregateVal as number)] + : d3.extent(groupedVals, (d) => d.aggregateVal as number), + ) + : config?.numColorScaleType === ENumericalColorScaleType.DIVERGENT + ? d3 + .scaleSequential( + d3.piecewise( + d3.interpolateRgb.gamma(2.2), + [ + '#003367', + '#16518a', + '#2e72ae', + '#5093cd', + '#77b5ea', + '#aad7fd', + '#F1F3F5', + '#fac7a9', + '#f99761', + '#e06d3b', + '#c2451a', + '#99230d', + '#6f0000', + ].reverse(), + ), + ) + .domain(d3.extent(groupedVals, (d) => d.aggregateVal as number)) + : null; + + const extGroupedVals = groupedVals.map((gV) => ({ + ...gV, + color: gV.aggregateVal === null ? 'white' : colorSc(gV.aggregateVal), + x: xSc(gV.xVal) + margin.left, + y: ySc(gV.yVal) + margin.top, + })); + + return { + groupedValues: extGroupedVals, + rectWidth: (width - margin.left - margin.right - interRectDistance * xSc.domain().length) / xSc.domain().length, + rectHeight: (height - margin.bottom - margin.top - interRectDistance * ySc.domain().length) / ySc.domain().length, + xScale: xSc, + yScale: ySc, + colorScale: colorSc, + }; + }, [aggregatedTable, width, margin.left, margin.right, margin.top, margin.bottom, height, config?.numColorScaleType, config.aggregateType]); + + const rects = useMemo(() => { + if (width === 0 || height === 0) return null; + return groupedValues.map((d, i) => { + const { aggregateVal, x, y, xVal, yVal, color } = d; + const ids: string[] = Array.from( + baseTable + .params({ x: xVal, y: yVal }) + .filter((b, $) => b.xVal === $.x && b.yVal === $.y) + .values('id'), + ); + return ( + selected[id])} + width={rectWidth} + height={rectHeight} + color={color} + label={aggregateVal} + setSelected={() => selectionCallback(ids)} + isImmediate={!config.isAnimationEnabled} + /> + ); + }); + }, [baseTable, groupedValues, height, rectHeight, rectWidth, selected, selectionCallback, width, xScale, yScale, config.isAnimationEnabled]); + + const text = useMemo(() => { + if (width === 0 || height === 0) return null; + return ( + + ); + }, [height, margin, rectHeight, rectWidth, width, xScale, yScale, config.isAnimationEnabled]); + + return ( + + + + + + + setExternalConfig({ + ...config, + ySortedBy: + config.ySortedBy === ESortTypes.CAT_ASC + ? ESortTypes.CAT_DESC + : config.ySortedBy === ESortTypes.CAT_DESC + ? ESortTypes.VAL_ASC + : config.ySortedBy === ESortTypes.VAL_ASC + ? ESortTypes.VAL_DESC + : ESortTypes.CAT_ASC, + }) + } + > + + {column2.info.name} + + + + + + {rects} + {text} + + + + + + setExternalConfig({ + ...config, + xSortedBy: + config.xSortedBy === ESortTypes.CAT_ASC + ? ESortTypes.CAT_DESC + : config.xSortedBy === ESortTypes.CAT_DESC + ? ESortTypes.VAL_ASC + : config.xSortedBy === ESortTypes.VAL_ASC + ? ESortTypes.VAL_DESC + : ESortTypes.CAT_ASC, + }) + } + > + + {column1.info.name} + + + ); +} diff --git a/src/vis/heatmap/HeatmapGrid.tsx b/src/vis/heatmap/HeatmapGrid.tsx new file mode 100644 index 000000000..05af2e414 --- /dev/null +++ b/src/vis/heatmap/HeatmapGrid.tsx @@ -0,0 +1,55 @@ +import { Loader, Stack } from '@mantine/core'; +import * as React from 'react'; +import { useAsync } from '../../hooks/useAsync'; +import { InvalidCols } from '../general/InvalidCols'; +import { VisColumn } from '../interfaces'; +import { Heatmap } from './Heatmap'; +import { IHeatmapConfig } from './interfaces'; +import { getHeatmapData } from './utils'; + +export function HeatmapGrid({ + config, + columns, + selected, + setExternalConfig, + selectionCallback, +}: { + config: IHeatmapConfig; + columns: VisColumn[]; + selectionCallback?: (ids: string[]) => void; + setExternalConfig?: (config: IHeatmapConfig) => void; + selected?: { [key: string]: boolean }; +}) { + const { value: allColumns, status } = useAsync(getHeatmapData, [columns, config.catColumnsSelected, config.aggregateColumn]); + const hasAtLeast2CatCols = allColumns?.catColumn && allColumns?.catColumn?.length > 1; + + const margin = React.useMemo(() => { + return { + top: 10, + right: 20, + bottom: 30, + left: 40, + }; + }, []); + + return ( + + {status === 'pending' ? ( + + ) : !hasAtLeast2CatCols ? ( + + ) : ( + + )} + + ); +} diff --git a/src/vis/heatmap/HeatmapRect.tsx b/src/vis/heatmap/HeatmapRect.tsx new file mode 100644 index 000000000..3d4d93c66 --- /dev/null +++ b/src/vis/heatmap/HeatmapRect.tsx @@ -0,0 +1,101 @@ +import { Tooltip } from '@mantine/core'; +import * as d3 from 'd3v7'; +import * as React from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useSpring, animated, easings } from 'react-spring'; + +const DELAY = 2000; +export function HeatmapRect({ + x, + y, + width, + height, + color, + label, + setSelected, + xOrder = 1, + yOrder = 1, + isSelected = false, + isImmediate = false, + onClick = () => null, +}: { + x: number; + y: number; + width: number; + height: number; + color: string; + label: number; + setSelected?: () => void; + xOrder?: number; + yOrder?: number; + isSelected?: boolean; + isImmediate?: boolean; + onClick?: (e: any) => void; +}) { + const [isHovered, setIsHovered] = useState(); + const currXOrder = useRef(xOrder); + const currYOrder = useRef(yOrder); + + isImmediate = isImmediate || (currXOrder.current === xOrder && currYOrder.current === yOrder); + + const colorSpring = useSpring({ fill: color, config: { duration: 750, easing: easings.easeInOutSine } }); + + const xSpring = useSpring({ + x, + config: { duration: DELAY, easing: easings.easeInOutSine }, + delay: isImmediate ? 0 : DELAY * xOrder, + immediate: isImmediate, + }); + const ySpring = useSpring({ + y, + config: { duration: DELAY, easing: easings.easeInOutSine }, + delay: isImmediate ? 0 : DELAY * yOrder, + immediate: isImmediate, + }); + + const rect = useMemo(() => { + return ( + { + if (xSpring.x.isAnimating || ySpring.y.isAnimating) return; + setIsHovered(true); + }} + onMouseLeave={() => { + if (xSpring.x.isAnimating || ySpring.y.isAnimating) return; + + setIsHovered(false); + }} + onMouseDown={(e) => { + if (xSpring.x.isAnimating || ySpring.y.isAnimating) return; + + setSelected(); + onClick(e); + }} + /> + ); + }, [colorSpring, height, isSelected, onClick, setSelected, width, xSpring, ySpring]); + + useEffect(() => { + currXOrder.current = xOrder; + currYOrder.current = yOrder; + }, [xOrder, yOrder]); + + const formatter = useMemo(() => { + return d3.format('.3s'); + }, []); + + return isHovered ? ( + + {rect} + + ) : ( + rect + ); +} diff --git a/src/vis/heatmap/HeatmapText.tsx b/src/vis/heatmap/HeatmapText.tsx new file mode 100644 index 000000000..37744f1e7 --- /dev/null +++ b/src/vis/heatmap/HeatmapText.tsx @@ -0,0 +1,108 @@ +import * as d3 from 'd3v7'; +import * as React from 'react'; +import { Group, Text, Tooltip } from '@mantine/core'; +import { useMemo } from 'react'; +import { AnimatedLine } from './AnimatedLine'; +import { AnimatedText } from './AnimatedText'; + +const textMargin = 2; +export function HeatmapText({ + margin, + yScale, + xScale, + width, + rectHeight, + height, + rectWidth, + isImmediate, +}: { + margin: { top: number; right: number; bottom: number; left: number }; + yScale: d3.ScaleBand; + xScale: d3.ScaleBand; + width: number; + height: number; + rectHeight: number; + rectWidth: number; + isImmediate: boolean; +}) { + const labelSpacing = useMemo(() => { + const maxLabelLength = d3.max(yScale.domain().map((m) => m.length)); + + return maxLabelLength > 5 ? 35 : maxLabelLength * 7; + }, [yScale]); + + return ( + + {xScale.domain().map((xVal, i) => ( + + + + + + + {xVal} + + + + + ))} + {yScale.domain().map((yVal, i) => ( + + + + + + + + {yVal} + + + + + + ))} + + ); +} diff --git a/src/vis/heatmap/HeatmapVis.tsx b/src/vis/heatmap/HeatmapVis.tsx new file mode 100644 index 000000000..f9eb0b73e --- /dev/null +++ b/src/vis/heatmap/HeatmapVis.tsx @@ -0,0 +1,22 @@ +import { Group } from '@mantine/core'; +import * as React from 'react'; +import { ICommonVisProps } from '../interfaces'; +import { HeatmapGrid } from './HeatmapGrid'; +import { IHeatmapConfig } from './interfaces'; + +export function HeatmapVis({ + config, + columns, + setConfig, + selectionCallback = () => null, + selectedMap = {}, + enableSidebar, + setShowSidebar, + showSidebar, +}: ICommonVisProps) { + return ( + + + + ); +} diff --git a/src/vis/heatmap/HeatmapVisSidebar.tsx b/src/vis/heatmap/HeatmapVisSidebar.tsx new file mode 100644 index 000000000..87c668d13 --- /dev/null +++ b/src/vis/heatmap/HeatmapVisSidebar.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Switch } from '@mantine/core'; +import { ColumnInfo, EAggregateTypes, EColumnTypes, VisColumn } from '../interfaces'; +import { AggregateTypeSelect } from '../sidebar/AggregateTypeSelect'; +import { CategoricalColumnSelect } from '../sidebar/CategoricalColumnSelect'; +import { NumericalColorButtons } from '../sidebar/NumericalColorButtons'; +import { IHeatmapConfig } from './interfaces'; +import { i18n } from '../../i18n'; + +export function HeatmapVisSidebar({ + config, + columns, + setConfig, +}: { + config: IHeatmapConfig; + columns: VisColumn[]; + setConfig: (config: IHeatmapConfig) => void; +}) { + return ( + <> + setConfig({ ...config, catColumnsSelected })} + columns={columns} + currentSelected={config.catColumnsSelected || []} + /> + {config?.catColumnsSelected?.length > 1 ? ( + setConfig({ ...config, numColorScaleType })} currentSelected={config.numColorScaleType} /> + ) : null} + { + if (config.aggregateColumn === null) { + setConfig({ + ...config, + aggregateType, + aggregateColumn: columns.find((col) => col.type === EColumnTypes.NUMERICAL).info, + }); + } else { + setConfig({ ...config, aggregateType }); + } + }} + aggregateColumnSelectCallback={(aggregateColumn: ColumnInfo) => setConfig({ ...config, aggregateColumn })} + columns={columns} + currentSelected={config.aggregateType} + aggregateColumn={config.aggregateColumn} + /> + {/* Disabled until the animations are fixed. By default animations are disabled */} + {/* setConfig({ ...config, isAnimationEnabled: event.currentTarget.checked })} + label={i18n.t('visyn:vis.animation')} + /> */} + + ); +} diff --git a/src/vis/heatmap/interfaces.ts b/src/vis/heatmap/interfaces.ts new file mode 100644 index 000000000..586e2f738 --- /dev/null +++ b/src/vis/heatmap/interfaces.ts @@ -0,0 +1,24 @@ +import { BaseVisConfig, ColumnInfo, EAggregateTypes, ENumericalColorScaleType, ESupportedPlotlyVis } from '../interfaces'; + +export interface IHeatmapConfig { + type: ESupportedPlotlyVis.HEATMAP; + color: ColumnInfo | null; + catColumnsSelected: ColumnInfo[]; + numColorScaleType: ENumericalColorScaleType; + xSortedBy: ESortTypes; + ySortedBy: ESortTypes; + aggregateType: EAggregateTypes; + aggregateColumn: ColumnInfo | null; + isAnimationEnabled: boolean; +} + +export enum ESortTypes { + CAT_ASC = 'CAT_ASC', + CAT_DESC = 'CAT_DESC', + VAL_ASC = 'VAL_ASC', + VAL_DESC = 'VAL_DESC', +} + +export function isHeatmapConfig(vis: BaseVisConfig): vis is IHeatmapConfig { + return vis.type === ESupportedPlotlyVis.HEATMAP; +} diff --git a/src/vis/heatmap/utils.ts b/src/vis/heatmap/utils.ts new file mode 100644 index 000000000..3109564a3 --- /dev/null +++ b/src/vis/heatmap/utils.ts @@ -0,0 +1,52 @@ +import merge from 'lodash/merge'; +import { resolveColumnValues, resolveSingleColumn } from '../general/layoutUtils'; +import { + ColumnInfo, + EAggregateTypes, + EColumnTypes, + ENumericalColorScaleType, + ESupportedPlotlyVis, + VisCategoricalValue, + VisColumn, + VisNumericalValue, +} from '../interfaces'; +import { ESortTypes, IHeatmapConfig } from './interfaces'; + +const defaultConfig: IHeatmapConfig = { + type: ESupportedPlotlyVis.HEATMAP, + color: null, + catColumnsSelected: [], + numColorScaleType: ENumericalColorScaleType.SEQUENTIAL, + xSortedBy: ESortTypes.CAT_ASC, + ySortedBy: ESortTypes.CAT_ASC, + aggregateColumn: null, + aggregateType: EAggregateTypes.COUNT, + isAnimationEnabled: false, +}; + +export function heatmapMergeDefaultConfig(columns: VisColumn[], config: IHeatmapConfig): IHeatmapConfig { + const merged = merge({}, defaultConfig, config); + return merged; +} + +export async function getHeatmapData( + columns: VisColumn[], + catColumnDesc: ColumnInfo[], + aggColumnDesc: ColumnInfo, +): Promise<{ + catColumn: { + resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; + type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL; + info: ColumnInfo; + }[]; + aggregateColumn: { + resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; + type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL; + info: ColumnInfo; + }; +}> { + const catColumn = await resolveColumnValues(columns.filter((col) => catColumnDesc.find((catCol) => catCol.id === col.info.id))); + const aggregateColumn = await resolveSingleColumn(aggColumnDesc ? columns.find((col) => col.info.id === aggColumnDesc.id) : null); + + return { catColumn, aggregateColumn }; +} diff --git a/src/vis/sidebar/HexOpacitySwitch.tsx b/src/vis/hexbin/HexOpacitySwitch.tsx similarity index 100% rename from src/vis/sidebar/HexOpacitySwitch.tsx rename to src/vis/hexbin/HexOpacitySwitch.tsx diff --git a/src/vis/hexbin/HexSizeSlider.tsx b/src/vis/hexbin/HexSizeSlider.tsx new file mode 100644 index 000000000..ba0692af4 --- /dev/null +++ b/src/vis/hexbin/HexSizeSlider.tsx @@ -0,0 +1,37 @@ +import { Input, Slider } from '@mantine/core'; +import debounce from 'lodash/debounce'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { useSyncedRef } from '../../hooks'; + +interface OpacitySliderProps { + callback: (n: number) => void; + currentValue: number; +} + +export function HexSizeSlider({ callback, currentValue }: OpacitySliderProps) { + const syncedCallback = useSyncedRef(callback); + + const debouncedCallback = useMemo(() => { + return debounce((n: number) => syncedCallback.current?.(n), 10); + }, [syncedCallback]); + + return ( + + { + debouncedCallback(n); + }} + /> + + ); +} diff --git a/src/vis/sidebar/HexSizeSwitch.tsx b/src/vis/hexbin/HexSizeSwitch.tsx similarity index 100% rename from src/vis/sidebar/HexSizeSwitch.tsx rename to src/vis/hexbin/HexSizeSwitch.tsx diff --git a/src/vis/sidebar/HexbinOptionSelect.tsx b/src/vis/hexbin/HexbinOptionSelect.tsx similarity index 93% rename from src/vis/sidebar/HexbinOptionSelect.tsx rename to src/vis/hexbin/HexbinOptionSelect.tsx index 5b3d52187..aa9bfeca9 100644 --- a/src/vis/sidebar/HexbinOptionSelect.tsx +++ b/src/vis/hexbin/HexbinOptionSelect.tsx @@ -1,7 +1,7 @@ import { Select } from '@mantine/core'; import * as React from 'react'; import { i18n } from '../../i18n'; -import { EHexbinOptions } from '../interfaces'; +import { EHexbinOptions } from './interfaces'; interface HexbinOptionSelectProps { callback: (c: EHexbinOptions) => void; diff --git a/src/vis/hexbin/HexbinVis.tsx b/src/vis/hexbin/HexbinVis.tsx index 402f62d5b..ddbaf2426 100644 --- a/src/vis/hexbin/HexbinVis.tsx +++ b/src/vis/hexbin/HexbinVis.tsx @@ -1,126 +1,181 @@ +import { Box, Center, Chip, Group, ScrollArea, Stack, Tooltip, rem } from '@mantine/core'; +import * as d3v7 from 'd3v7'; import * as React from 'react'; -import uniqueId from 'lodash/uniqueId'; -import merge from 'lodash/merge'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { ActionIcon, Center, Container, Group, SimpleGrid, Stack, Tooltip } from '@mantine/core'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faGear } from '@fortawesome/free-solid-svg-icons/faGear'; -import { VisColumn, IVisConfig, IHexbinConfig, EScatterSelectSettings } from '../interfaces'; -import { InvalidCols } from '../general'; +import { useAsync } from '../../hooks/useAsync'; import { i18n } from '../../i18n'; -import { Hexplot } from './Hexplot'; -import { HexbinVisSidebar } from './HexbinVisSidebar'; -import { VisSidebarWrapper } from '../VisSidebarWrapper'; +import { InvalidCols } from '../general'; +import { EScatterSelectSettings, ICommonVisProps } from '../interfaces'; import { BrushOptionButtons } from '../sidebar'; -import { useSyncedRef } from '../../hooks/useSyncedRef'; -import { VisSidebarOpenButton } from '../VisSidebarOpenButton'; +import { Hexplot } from './Hexplot'; +import { IHexbinConfig } from './interfaces'; +import { getHexData } from './utils'; -const defaultExtensions = { - prePlot: null, - postPlot: null, - preSidebar: null, - postSidebar: null, -}; +function Legend({ + categories, + filteredCategories, + colorScale, + onClick, + height, +}: { + categories: string[]; + filteredCategories: string[]; + colorScale: d3v7.ScaleOrdinal; + onClick: (string) => void; + height: number; +}) { + return ( + + + {categories.map((c) => { + return ( + + + onClick(c)} + checked={false} + styles={{ + label: { + width: '100%', + backgroundColor: filteredCategories.includes(c) ? 'lightgrey' : `${colorScale(c)} !important`, + textAlign: 'center', + paddingLeft: '10px', + paddingRight: '10px', + overflow: 'hidden', + color: filteredCategories.includes(c) ? 'black' : 'white', + textOverflow: 'ellipsis', + }, + }} + > + {c} + + + + ); + })} + + + ); +} export function HexbinVis({ config, - extensions, columns, + dimensions, setConfig, selectionCallback = () => null, - selected = {}, - enableSidebar, - setShowSidebar, - showSidebar, + selectedMap = {}, showDragModeOptions = true, -}: { - config: IHexbinConfig; - extensions?: { - prePlot?: React.ReactNode; - postPlot?: React.ReactNode; - preSidebar?: React.ReactNode; - postSidebar?: React.ReactNode; - }; - columns: VisColumn[]; - setConfig: (config: IVisConfig) => void; - selectionCallback?: (ids: string[]) => void; - selected?: { [key: string]: boolean }; - showSidebar?: boolean; - setShowSidebar?(show: boolean): void; - showDragModeOptions?: boolean; - enableSidebar?: boolean; -}) { - const mergedExtensions = useMemo(() => { - return merge({}, defaultExtensions, extensions); - }, [extensions]); +}: ICommonVisProps) { + const { width, height } = dimensions; + const { value: allColumns, status: colsStatus } = useAsync(getHexData, [columns, config.numColumnsSelected, config.color]); - const ref = useRef(); + const [filteredCategories, setFilteredCategories] = React.useState([]); - return ( - - {enableSidebar ? setShowSidebar(!showSidebar)} isOpen={showSidebar} /> : null} + const currentColorColumn = React.useMemo(() => { + if (config.color && allColumns?.colorColVals) { + return { + allValues: allColumns.colorColVals.resolvedValues, + filteredValues: allColumns.colorColVals.resolvedValues.filter((val) => !filteredCategories.includes(val.val as string)), + }; + } - - {showDragModeOptions ? ( -
- - setConfig({ ...config, dragMode })} - options={[EScatterSelectSettings.RECTANGLE, EScatterSelectSettings.PAN]} - dragMode={config.dragMode} - /> - -
- ) : null} - 2 ? config.numColumnsSelected.length : 1}> + return null; + }, [allColumns?.colorColVals, config.color, filteredCategories]); + + const colorScale = React.useMemo(() => { + if (!currentColorColumn?.allValues) { + return null; + } + + const colorOptions = currentColorColumn.allValues.map((val) => val.val as string); + + return d3v7 + .scaleOrdinal(allColumns.colorColVals.color ? Object.keys(allColumns.colorColVals.color) : d3v7.schemeCategory10) + .domain(allColumns.colorColVals.color ? Object.values(allColumns.colorColVals.color) : Array.from(new Set(colorOptions))); + }, [currentColorColumn, allColumns]); + + return ( + + {showDragModeOptions ? ( +
+ + setConfig({ ...config, dragMode })} + options={[EScatterSelectSettings.RECTANGLE, EScatterSelectSettings.PAN]} + dragMode={config.dragMode} + /> + +
+ ) : null} + + 2 + ? { gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gridTemplateRows: 'repeat(3, minmax(0, 1fr))', gap: '1rem 1rem' } + : { gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', gridTemplateRows: 'repeat(1, minmax(0, 1fr))', gap: '1rem 1rem' }), + }} + > {config.numColumnsSelected.length < 2 ? ( - ) : ( - <> - {config.numColumnsSelected.length > 2 ? ( - config.numColumnsSelected.map((xCol) => { - return config.numColumnsSelected.map((yCol) => { - if (xCol.id !== yCol.id) { - return ( - col.info.id === yCol.id), - columns.find((col) => col.info.id === xCol.id), - columns.find((col) => col.info.id === config.color?.id), - ]} - /> - ); - } + ) : null} - return
; - }); - }) - ) : ( - col.info.id === config.numColumnsSelected[0].id), - columns.find((col) => col.info.id === config.numColumnsSelected[1].id), - columns.find((col) => col.info.id === config.color?.id), - ]} - /> - )} - {mergedExtensions.postPlot} - - )} - - - {showSidebar ? ( - - - - ) : null} - + {config.numColumnsSelected.length === 2 && allColumns?.numColVals.length === config.numColumnsSelected.length && colsStatus === 'success' ? ( + + ) : null} + {config.numColumnsSelected.length > 2 && allColumns?.numColVals.length === config.numColumnsSelected.length && colsStatus === 'success' + ? config.numColumnsSelected.map((xCol) => { + return config.numColumnsSelected.map((yCol) => { + if (xCol.id !== yCol.id) { + return ( + col.info.id === yCol.id), + allColumns.numColVals.find((col) => col.info.id === xCol.id), + ], + colorColVals: allColumns.colorColVals, + }} + /> + ); + } + + return
; + }); + }) + : null} + + {currentColorColumn ? ( +
+ + filteredCategories.includes(s) + ? setFilteredCategories(filteredCategories.filter((f) => f !== s)) + : setFilteredCategories([...filteredCategories, s]) + } + height={height - 100} + /> +
+ ) : null} + + ); } diff --git a/src/vis/hexbin/HexbinVisSidebar.tsx b/src/vis/hexbin/HexbinVisSidebar.tsx index 0970c9b89..6d973f726 100644 --- a/src/vis/hexbin/HexbinVisSidebar.tsx +++ b/src/vis/hexbin/HexbinVisSidebar.tsx @@ -1,71 +1,35 @@ import * as React from 'react'; -import { useMemo } from 'react'; -import merge from 'lodash/merge'; -import { Container, Divider, Stack } from '@mantine/core'; -import { ColumnInfo, EColumnTypes, EHexbinOptions, ESupportedPlotlyVis, IHexbinConfig, IVisConfig, VisColumn } from '../interfaces'; -import { VisTypeSelect } from '../sidebar/VisTypeSelect'; +import { ColumnInfo, EColumnTypes, ICommonVisSideBarProps } from '../interfaces'; import { NumericalColumnSelect } from '../sidebar'; import { SingleColumnSelect } from '../sidebar/SingleColumnSelect'; -import { HexSizeSlider } from '../sidebar/HexSizeSlider'; -import { HexbinOptionSelect } from '../sidebar/HexbinOptionSelect'; -import { HexSizeSwitch } from '../sidebar/HexSizeSwitch'; -import { HexOpacitySwitch } from '../sidebar/HexOpacitySwitch'; - -interface DensityVisSidebarProps { - config: IHexbinConfig; - extensions?: { - prePlot?: React.ReactNode; - postPlot?: React.ReactNode; - preSidebar?: React.ReactNode; - postSidebar?: React.ReactNode; - }; - columns: VisColumn[]; - setConfig: (config: IVisConfig) => void; -} - -const defaultExtensions = { - prePlot: null, - postPlot: null, - preSidebar: null, - postSidebar: null, -}; - -export function HexbinVisSidebar({ config, extensions, columns, setConfig }: DensityVisSidebarProps) { - const mergedExtensions = useMemo(() => { - return merge({}, defaultExtensions, extensions); - }, [extensions]); +import { HexOpacitySwitch } from './HexOpacitySwitch'; +import { HexSizeSlider } from './HexSizeSlider'; +import { HexSizeSwitch } from './HexSizeSwitch'; +import { HexbinOptionSelect } from './HexbinOptionSelect'; +import { EHexbinOptions, IHexbinConfig } from './interfaces'; +export function HexbinVisSidebar({ config, columns, setConfig }: ICommonVisSideBarProps) { return ( - - - setConfig({ ...(config as any), type })} currentSelected={config.type} /> - - - setConfig({ ...config, numColumnsSelected })} - columns={columns} - currentSelected={config.numColumnsSelected || []} - /> - setConfig({ ...config, color })} - columns={columns} - currentSelected={config.color} - /> - {config.color ? ( - setConfig({ ...config, hexbinOptions })} currentSelected={config.hexbinOptions} /> - ) : null} - - - - setConfig({ ...config, hexRadius })} /> - setConfig({ ...config, isSizeScale })} /> - setConfig({ ...config, isOpacityScale })} /> - - {mergedExtensions.preSidebar} - {mergedExtensions.postSidebar} - - + <> + setConfig({ ...config, numColumnsSelected })} + columns={columns} + currentSelected={config.numColumnsSelected || []} + /> + setConfig({ ...config, color })} + columns={columns} + currentSelected={config.color} + /> + {config.color ? ( + setConfig({ ...config, hexbinOptions })} currentSelected={config.hexbinOptions} /> + ) : null} + + setConfig({ ...config, hexRadius })} /> + setConfig({ ...config, isSizeScale })} /> + setConfig({ ...config, isOpacityScale })} /> + ); } diff --git a/src/vis/hexbin/Hexplot.tsx b/src/vis/hexbin/Hexplot.tsx index 301c10894..ed3bfe64b 100644 --- a/src/vis/hexbin/Hexplot.tsx +++ b/src/vis/hexbin/Hexplot.tsx @@ -1,4 +1,5 @@ -import { Container, Stack, Chip, Tooltip, Box, ScrollArea } from '@mantine/core'; +import { Box, Chip, Container, ScrollArea, Stack, Tooltip } from '@mantine/core'; +import { useElementSize } from '@mantine/hooks'; import * as hex from 'd3-hexbin'; import { HexbinBin } from 'd3-hexbin'; import * as d3v7 from 'd3v7'; @@ -6,87 +7,35 @@ import { D3BrushEvent, D3ZoomEvent } from 'd3v7'; import uniqueId from 'lodash/uniqueId'; import * as React from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { useAsync } from '../../hooks/useAsync'; -import { VisColumn, IHexbinConfig, EScatterSelectSettings } from '../interfaces'; +import { EScatterSelectSettings } from '../interfaces'; import { SingleHex } from './SingleHex'; -import { getHexData } from './utils'; import { XAxis } from './XAxis'; import { YAxis } from './YAxis'; +import { IHexbinConfig } from './interfaces'; +import { ResolvedHexValues } from './utils'; interface HexagonalBinProps { config: IHexbinConfig; - columns: VisColumn[]; + allColumns: ResolvedHexValues; selectionCallback?: (ids: string[]) => void; selected?: { [key: string]: boolean }; -} - -function Legend({ - categories, - filteredCategories, - colorScale, - onClick, - height, -}: { - categories: string[]; filteredCategories: string[]; - colorScale: d3v7.ScaleOrdinal; - onClick: (string) => void; - height: number; -}) { - return ( - - - {categories.map((c) => { - return ( - - - onClick(c)} - checked={false} - styles={{ - label: { - width: '100%', - backgroundColor: filteredCategories.includes(c) ? 'lightgrey' : `${colorScale(c)} !important`, - textAlign: 'center', - paddingLeft: '10px', - paddingRight: '10px', - overflow: 'hidden', - color: filteredCategories.includes(c) ? 'black' : 'white', - textOverflow: 'ellipsis', - }, - }} - > - {c} - - - - ); - })} - - - ); } -export function Hexplot({ config, columns, selectionCallback = () => null, selected = {} }: HexagonalBinProps) { - const ref = useRef(null); - const [height, setHeight] = useState(0); - const [width, setWidth] = useState(0); +export function Hexplot({ config, allColumns, selectionCallback = () => null, selected = {}, filteredCategories }: HexagonalBinProps) { + const { ref: hexRef, width: realWidth, height: realHeight } = useElementSize(); + const xZoomedScale = useRef>(null); const yZoomedScale = useRef>(null); const [xZoomTransform, setXZoomTransform] = useState(0); const [yZoomTransform, setYZoomTransform] = useState(0); const [zoomScale, setZoomScale] = useState(1); - const [filteredCategories, setFilteredCategories] = useState([]); - - const { value: allColumns, status: colsStatus } = useAsync(getHexData, [columns, config.numColumnsSelected, config.color]); - const id = React.useMemo(() => uniqueId('HexPlot'), []); // getting current categorical column values, original and filtered const currentColorColumn = useMemo(() => { - if (colsStatus === 'success' && config.color && allColumns.colorColVals) { + if (config.color && allColumns.colorColVals) { return { allValues: allColumns.colorColVals.resolvedValues, filteredValues: allColumns.colorColVals.resolvedValues.filter((val) => !filteredCategories.includes(val.val as string)), @@ -94,20 +43,23 @@ export function Hexplot({ config, columns, selectionCallback = () => null, selec } return null; - }, [allColumns?.colorColVals, config.color, colsStatus, filteredCategories]); + }, [allColumns?.colorColVals, config.color, filteredCategories]); const margin = useMemo(() => { return { - left: 52, - right: config.color ? 80 : 25, - top: 50, - bottom: 53, + left: 48, + right: 16, + top: 48, + bottom: 48, }; - }, [config.color]); + }, []); + + const height = realHeight - margin.top - margin.bottom; + const width = realWidth - margin.left - margin.right; // getting currentX data values, both original and filtered. const currentX = useMemo(() => { - if (colsStatus === 'success' && allColumns) { + if (allColumns) { if (config.color && allColumns.colorColVals) { return { allValues: allColumns.numColVals[0].resolvedValues, @@ -123,11 +75,11 @@ export function Hexplot({ config, columns, selectionCallback = () => null, selec } return null; - }, [allColumns, config.color, colsStatus, filteredCategories]); + }, [allColumns, config.color, filteredCategories]); // getting currentY data values, both original and filtered. const currentY = useMemo(() => { - if (colsStatus === 'success' && allColumns) { + if (allColumns) { if (config.color && allColumns.colorColVals) { return { allValues: allColumns.numColVals[1].resolvedValues, @@ -143,23 +95,7 @@ export function Hexplot({ config, columns, selectionCallback = () => null, selec } return null; - }, [allColumns, colsStatus, config.color, filteredCategories]); - - // resize observer for setting size of the svg and updating on size change - useEffect(() => { - const ro = new ResizeObserver((entries: ResizeObserverEntry[]) => { - setHeight(entries[0].contentRect.height - margin.top - margin.bottom); - setWidth(entries[0].contentRect.width - margin.left - margin.right); - }); - - if (ref) { - ro.observe(ref.current); - } - - return () => { - ro.disconnect(); - }; - }, [margin]); + }, [allColumns, config.color, filteredCategories]); // create x scale const xScale = useMemo(() => { @@ -230,32 +166,28 @@ export function Hexplot({ config, columns, selectionCallback = () => null, selec // simple radius scale for the hexes const radiusScale = useMemo(() => { - if (colsStatus === 'success') { - const [min, max] = d3v7.extent(hexes, (h) => h.length); + const [min, max] = d3v7.extent(hexes, (h) => h.length); - return d3v7 - .scaleLinear() - .domain([min, max]) - .range([config.hexRadius / 2, config.hexRadius]); - } + return d3v7 + .scaleLinear() + .domain([min, max]) + .range([config.hexRadius / 2, config.hexRadius]); return null; - }, [colsStatus, hexes, config.hexRadius]); + }, [hexes, config.hexRadius]); // simple opacity scale for the hexes const opacityScale = useMemo(() => { - if (colsStatus === 'success') { - const [min, max] = d3v7.extent(hexes, (h) => h.length); + const [min, max] = d3v7.extent(hexes, (h) => h.length); - return d3v7.scaleLinear().domain([min, max]).range([0.1, 1]); - } + return d3v7.scaleLinear().domain([min, max]).range([0.1, 1]); return null; - }, [colsStatus, hexes]); + }, [hexes]); // Create a default color scale const colorScale = useMemo(() => { - if (colsStatus !== 'success' || !currentColorColumn?.allValues) { + if (!currentColorColumn?.allValues) { return null; } @@ -264,7 +196,7 @@ export function Hexplot({ config, columns, selectionCallback = () => null, selec return d3v7 .scaleOrdinal(allColumns.colorColVals.color ? Object.keys(allColumns.colorColVals.color) : d3v7.schemeCategory10) .domain(allColumns.colorColVals.color ? Object.values(allColumns.colorColVals.color) : Array.from(new Set(colorOptions))); - }, [allColumns, colsStatus, currentColorColumn]); + }, [allColumns, currentColorColumn]); // memoize the actual hexes since they do not need to change on zoom/drag const hexObjects = React.useMemo(() => { @@ -381,7 +313,7 @@ export function Hexplot({ config, columns, selectionCallback = () => null, selec }, [width, height, id, hexes, selectionCallback, config.dragMode, xScale, yScale, margin]); return ( - + null, selec pointerEvents={config.dragMode === EScatterSelectSettings.PAN ? 'auto' : 'none'} /> -
- - filteredCategories.includes(s) - ? setFilteredCategories(filteredCategories.filter((f) => f !== s)) - : setFilteredCategories([...filteredCategories, s]) - } - height={200} - /> -
); diff --git a/src/vis/hexbin/SingleHex.tsx b/src/vis/hexbin/SingleHex.tsx index 43fdc6564..2fe25fc7a 100644 --- a/src/vis/hexbin/SingleHex.tsx +++ b/src/vis/hexbin/SingleHex.tsx @@ -2,10 +2,9 @@ import * as React from 'react'; import * as hex from 'd3-hexbin'; import * as d3v7 from 'd3v7'; import { useMemo } from 'react'; -import { Tooltip } from '@mantine/core'; import { PieChart } from './PieChart'; import { cutHex } from './utils'; -import { EHexbinOptions } from '../interfaces'; +import { EHexbinOptions } from './interfaces'; export interface SingleHexProps { hexbinOption: EHexbinOptions; diff --git a/src/vis/hexbin/XAxis.tsx b/src/vis/hexbin/XAxis.tsx index 14facd59d..d619eaf92 100644 --- a/src/vis/hexbin/XAxis.tsx +++ b/src/vis/hexbin/XAxis.tsx @@ -12,13 +12,17 @@ export function XAxis({ xScale, yRange, vertPosition }) { return ( <> - - + {yRange ? ( + + ) : null} + {yRange ? ( + + ) : null} {ticks.map(({ value, xOffset }) => ( - + {yRange ? : null} c.type === EColumnTypes.NUMERICAL); @@ -46,11 +40,7 @@ export function hexinbMergeDefaultConfig(columns: VisColumn[], config: IHexbinCo return merged; } -export async function getHexData( - columns: VisColumn[], - numColumnsSelected: ColumnInfo[], - colorColumn: ColumnInfo | null, -): Promise<{ +export type ResolvedHexValues = { numColVals: { resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL; @@ -62,8 +52,10 @@ export async function getHexData( color?: Record; info: ColumnInfo; }; -}> { - const numCols: VisNumericalColumn[] = [columns[0] as VisNumericalColumn, columns[1] as VisNumericalColumn]; +}; + +export async function getHexData(columns: VisColumn[], numColumnsSelected: ColumnInfo[], colorColumn: ColumnInfo | null): Promise { + const numCols: VisNumericalColumn[] = columns.filter((col) => numColumnsSelected.find((e) => e.id === col.info.id)) as VisNumericalColumn[]; const numColVals = await resolveColumnValues(numCols); diff --git a/src/vis/index.ts b/src/vis/index.ts index 4ccb57fea..e967b592b 100644 --- a/src/vis/index.ts +++ b/src/vis/index.ts @@ -5,8 +5,18 @@ // export * from './violin'; // export * from './Vis'; export * from './LazyVis'; +export * from './LineupVisWrapper'; +export * from './VisSidebar'; export * from './general'; export * from './interfaces'; -export * from './VisSidebar'; export * from './sidebar'; -export * from './LineupVisWrapper'; + +// Export interfaces ONLY since else the lazy loading will break +export * from './bar/interfaces'; +export * from './correlation/interfaces'; +export * from './heatmap/interfaces'; +export * from './violin/interfaces'; +export * from './hexbin/interfaces'; +export * from './scatter/interfaces'; +export * from './raincloud/interfaces'; +export * from './sankey/interfaces'; diff --git a/src/vis/interfaces.ts b/src/vis/interfaces.ts index 45e99396a..c42dd3be2 100644 --- a/src/vis/interfaces.ts +++ b/src/vis/interfaces.ts @@ -5,36 +5,14 @@ export enum ESupportedPlotlyVis { VIOLIN = 'Violin plot', BAR = 'Bar chart', HEXBIN = 'Hexbin plot', + HEATMAP = 'Heatmap plot', + RAINCLOUD = 'Raincloud plot', + SANKEY = 'Sankey', + CORRELATION = 'Correlation plot', } -export const allVisTypes: ESupportedPlotlyVis[] = [ - ESupportedPlotlyVis.SCATTER, - ESupportedPlotlyVis.BAR, - ESupportedPlotlyVis.VIOLIN, - ESupportedPlotlyVis.HEXBIN, -]; - -export type IVisConfig = IScatterConfig | IViolinConfig | IBarConfig | IHexbinConfig; - -export enum EBarDisplayType { - ABSOLUTE = 'Absolute', - NORMALIZED = 'Normalized', -} - -export enum EHexbinOptions { - COLOR = 'Color', - PIE = 'Pie', - BINS = 'Bins', -} - -export enum EBarDirection { - VERTICAL = 'Vertical', - HORIZONTAL = 'Horizontal', -} - -export enum EViolinOverlay { - NONE = 'None', - BOX = 'Box', +export interface BaseVisConfig { + type: string; } export enum EAggregateTypes { @@ -45,22 +23,11 @@ export enum EAggregateTypes { MAX = 'Maximum', } -export enum EBarGroupingType { - STACK = 'Stacked', - GROUP = 'Grouped', -} - export enum EColumnTypes { NUMERICAL = 'Numerical', CATEGORICAL = 'Categorical', } -export enum EGeneralFormType { - DROPDOWN = 'Dropdown', - BUTTON = 'Button', - SLIDER = 'Slider', -} - export enum EFilterOptions { IN = 'Filter in', OUT = 'Filter out', @@ -79,45 +46,9 @@ export enum EScatterSelectSettings { PAN = 'pan', } -export interface IViolinConfig { - type: ESupportedPlotlyVis.VIOLIN; - numColumnsSelected: ColumnInfo[]; - catColumnsSelected: ColumnInfo[]; - violinOverlay: EViolinOverlay; -} - -export interface IScatterConfig { - type: ESupportedPlotlyVis.SCATTER; - numColumnsSelected: ColumnInfo[]; - color: ColumnInfo | null; - numColorScaleType: ENumericalColorScaleType; - shape: ColumnInfo | null; - dragMode: EScatterSelectSettings; - alphaSliderVal: number; -} - -export interface IBarConfig { - type: ESupportedPlotlyVis.BAR; - multiples: ColumnInfo | null; - group: ColumnInfo | null; - direction: EBarDirection; - display: EBarDisplayType; - groupType: EBarGroupingType; - numColumnsSelected: ColumnInfo[]; - catColumnSelected: ColumnInfo; - aggregateType: EAggregateTypes; - aggregateColumn: ColumnInfo | null; -} - -export interface IHexbinConfig { - type: ESupportedPlotlyVis.HEXBIN; - numColumnsSelected: ColumnInfo[]; - color: ColumnInfo | null; - hexRadius: number; - isOpacityScale: boolean; - isSizeScale: boolean; - dragMode: EScatterSelectSettings; - hexbinOptions: EHexbinOptions; +export enum EScaleType { + LINEAR = 'Linear', + LOG = 'Log', } type ValueGetter = () => T | Promise; @@ -190,7 +121,41 @@ export type Scales = { /** * Common props for all vis sidebars. */ -export interface ICommonVisSideBarProps { +export interface ICommonVisSideBarProps { style?: React.CSSProperties | undefined; className?: string | undefined; + columns: VisColumn[]; + optionsConfig?: any; + filterCallback?: (s: EFilterOptions) => void; + config: T; + setConfig: (c: T) => void; +} + +export interface ICommonVisProps { + config?: T; + setConfig?: (config: T) => void; + columns: VisColumn[]; + optionsConfig?: any; + colors?: string[]; + shapes?: string[]; + filterCallback?: (s: EFilterOptions) => void; + selectionCallback?: (s: string[]) => void; + selectedMap?: { [key: string]: boolean }; + selectedList?: string[]; + showCloseButton?: boolean; + closeButtonCallback?: () => void; + scales?: Scales; + enableSidebar?: boolean; + showSidebar?: boolean; + showSidebarDefault?: boolean; + setShowSidebar?: (s: boolean) => void; + extensions?: { + prePlot?: React.ReactNode; + postPlot?: React.ReactNode; + preSidebar?: React.ReactNode; + postSidebar?: React.ReactNode; + }; + scrollZoom?: boolean; + showDragModeOptions?: boolean; + dimensions: { width: number; height: number }; } diff --git a/src/vis/legend/ColorLegend.tsx b/src/vis/legend/ColorLegend.tsx new file mode 100644 index 000000000..0b238d6c0 --- /dev/null +++ b/src/vis/legend/ColorLegend.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { Group, Stack, Text } from '@mantine/core'; +import * as d3 from 'd3v7'; +import { useEffect, useMemo, useRef } from 'react'; + +export function ColorLegend({ + scale, + width = 250, + height = 20, + range = [0, 1.1], + tickCount = 5, + format = '.3s', + rightMargin = 40, + title = null, +}: { + scale: (t: number) => string; + width?: number; + height?: number; + range?: [number, number]; + tickCount?: number; + format?: string; + rightMargin?: number; + title: string; +}) { + const colors = d3 + .range(tickCount) + .reverse() + .map((score) => { + const num = (range[1] - range[0]) * (score / (tickCount - 1)) + range[0]; + return { color: scale(num), score: num }; + }); + + const canvasRef = useRef(null); + + useEffect(() => { + const canvas: HTMLCanvasElement = document.getElementById('proteomicsLegendCanvas') as HTMLCanvasElement; + + const context = canvas.getContext('2d'); + canvas.width = width; + canvas.height = height; + canvas.style.margin = '0'; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + canvas.style.imageRendering = 'pixelated'; + + const t = d3 + .range(height) + .map((i) => (i / height) * (range[1] - range[0])) + .reverse(); + + for (let i = t.length - 1; i >= 0; i--) { + context.fillStyle = scale(t[i] + range[0]); + context.fillRect(0, i, width, 1); + } + }, [scale, width, height, range]); + + const formatFunc = useMemo(() => { + return d3.format(format); + }, [format]); + + return ( + + + + {colors.map((color, i) => ( + // idk why this doesnt work when i use the score as the key, tbh. The scores definitely are unique, but something to do with the 0 changing on render, idk + // eslint-disable-next-line react/no-array-index-key + + {formatFunc(color.score)} + + ))} + + {title ? ( + + {title} + + ) : null} + + ); +} diff --git a/src/vis/legend/ColorLegendVert.tsx b/src/vis/legend/ColorLegendVert.tsx new file mode 100644 index 000000000..3d4962358 --- /dev/null +++ b/src/vis/legend/ColorLegendVert.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import { Group, Stack, Text } from '@mantine/core'; +import * as d3 from 'd3v7'; +import { useEffect, useMemo, useRef } from 'react'; + +export function ColorLegendVert({ + scale, + width = 250, + height = 20, + range = [0, 1.1], + tickCount = 5, + title = null, + format = '.3s', +}: { + scale: (t: number) => string; + width?: number; + height?: number; + range?: [number, number]; + tickCount?: number; + title: string; + format?: string; +}) { + const colors = d3 + .range(tickCount) + .reverse() + .map((score) => { + const num = (range[1] - range[0]) * (score / (tickCount - 1)) + range[0]; + return { color: scale(num), score: num }; + }); + + const canvasRef = useRef(null); + + useEffect(() => { + const canvas: HTMLCanvasElement = document.getElementById('proteomicsLegendCanvas') as HTMLCanvasElement; + + const context = canvas.getContext('2d'); + canvas.height = height; + canvas.width = width; + + canvas.style.margin = '0'; + canvas.style.height = `${height}px`; + canvas.style.imageRendering = 'pixelated'; + + const t = d3 + .range(width) + .map((i) => (i / width) * (range[1] - range[0])) + .reverse(); + + for (let i = t.length - 1; i >= 0; i--) { + context.fillStyle = scale(t[i] + range[0]); + context.fillRect(i, 0, 1, height); + } + }, [scale, width, height, range]); + + const formatFunc = useMemo(() => { + return d3.format(format); + }, [format]); + + return ( + + {title ? ( + + {title} + + ) : null} + + + + {colors.map((color, i) => ( + // idk why this doesnt work when i use the score as the key, tbh. The scores definitely are unique, but something to do with the 0 changing on render, idk + // eslint-disable-next-line react/no-array-index-key + + {formatFunc(color.score)} + + ))} + + + ); +} diff --git a/src/vis/raincloud/AggregateRainSwitch.tsx b/src/vis/raincloud/AggregateRainSwitch.tsx new file mode 100644 index 000000000..335e0d399 --- /dev/null +++ b/src/vis/raincloud/AggregateRainSwitch.tsx @@ -0,0 +1,11 @@ +import { Switch } from '@mantine/core'; +import * as React from 'react'; + +interface AggregateRainSwitchProps { + callback: (b: boolean) => void; + currentValue: boolean; +} + +export function AggregateRainSwitch({ callback, currentValue }: AggregateRainSwitchProps) { + return callback(event.currentTarget.checked)} label="Aggregate rain" />; +} diff --git a/src/vis/raincloud/Brush.tsx b/src/vis/raincloud/Brush.tsx new file mode 100644 index 000000000..37fac6408 --- /dev/null +++ b/src/vis/raincloud/Brush.tsx @@ -0,0 +1,39 @@ +import * as d3 from 'd3v7'; +import React, { useEffect } from 'react'; + +export function Brush({ + x, + y, + height, + width, + id, + onBrush, +}: { + y: number; + x: number; + height: number; + width: number; + id: string; + onBrush: (brushArea: [number, number]) => void; +}) { + useEffect(() => { + const brush = d3 + .brushX() + .extent([ + [x, y], + [x + width, y + height], + ]) + .on('brush', (e) => { + onBrush(e.selection as [number, number]); + }) + .on('end', (e) => { + if (!e.selection) { + onBrush(null); + } + }); + + d3.select(`#brush${id}`).call(brush); + }, [height, id, onBrush, width, x, y]); + + return ; +} diff --git a/src/vis/raincloud/Raincloud.tsx b/src/vis/raincloud/Raincloud.tsx new file mode 100644 index 000000000..6c06e3aa6 --- /dev/null +++ b/src/vis/raincloud/Raincloud.tsx @@ -0,0 +1,193 @@ +import { Box, Container } from '@mantine/core'; +import { useResizeObserver } from '@mantine/hooks'; +import { op, table } from 'arquero'; +import React, { useCallback, useMemo, useState } from 'react'; +import { ColumnInfo, EColumnTypes, VisCategoricalValue, VisNumericalValue } from '../interfaces'; + +import { XAxis } from '../hexbin/XAxis'; +import { Brush } from './Brush'; +import { Heatmap } from './cloud/Heatmap'; +import { Histogram } from './cloud/Histogram'; +import { SplitViolin } from './cloud/SplitViolin'; +import { useXScale } from './hooks/useXScale'; +import { ECloudType, ELightningType, ERainType, IRaincloudConfig, IRaindropCircle } from './interfaces'; +import { Boxplot } from './lightning/Boxplot'; +import { Mean } from './lightning/Mean'; +import { MeanAndInterval } from './lightning/MeanAndInterval'; +import { MedianAndInterval } from './lightning/MedianAndInterval'; +import { BeeSwarm } from './rain/BeeSwarm'; +import { Circle } from './rain/Circle'; +import { DotPlot } from './rain/DotPlot'; +import { StripPlot } from './rain/StripPlot'; +import { WheatPlot } from './rain/WheatPlot'; + +const margin = { + top: 0, + left: 20, + right: 20, + bottom: 0, +}; + +const MAX_NON_AGGREGATED_COUNT = 400; + +export function Raincloud({ + column, + config, + selectionCallback, + selected, +}: { + column: { + resolvedValues: (VisNumericalValue | VisCategoricalValue)[]; + type: EColumnTypes; + info: ColumnInfo; + }; + config: IRaincloudConfig; + selectionCallback: (ids: string[]) => void; + selected: { [key: string]: boolean }; +}) { + const [ref, { width, height }] = useResizeObserver(); + + const baseTable = useMemo(() => { + return table({ values: column.resolvedValues.map((d) => d.val), ids: column.resolvedValues.map((d) => d.id) }); + }, [column.resolvedValues]); + + const [circles, setCircles] = useState([]); + + const xScale = useXScale({ range: [margin.left, width - margin.right], column }); + + const circlesCallback = useCallback((callbackCircles: IRaindropCircle[]) => { + setCircles(callbackCircles); + }, []); + + const circlesRendered = useMemo(() => { + return circles.map((circle) => { + return ( + selected[s]) ? '#E29609' : 'cornflowerblue'} + /> + ); + }); + // Hacking a bit here so the circles dont render twice, which would disable the animation. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [circles, selected]); + + const brushCallback = useCallback( + (range: [number, number]) => { + if (range === null) { + selectionCallback([]); + return; + } + const newRange = [xScale.invert(range[0]), xScale.invert(range[1])]; + const selectedIds = baseTable + .params({ lowRange: newRange[0], highRange: newRange[1] }) + .filter((d, params) => d.values >= params.lowRange && d.values <= params.highRange) + .array('ids'); + + selectionCallback(selectedIds); + }, + [baseTable, selectionCallback, xScale], + ); + + const aggregatedTable = useMemo(() => { + return baseTable + .orderby('values') + .derive({ percentRank: op.ntile(100) }) + .groupby('percentRank') + .rollup({ values: op.mean('values'), ids: op.array_agg('ids') }); + }, [baseTable]); + + return ( + + + + {width !== 0 && height !== 0 ? ( + + + {column.info.name} + + + {config.cloudType === ECloudType.HEATMAP ? ( + + ) : config.cloudType === ECloudType.HISTOGRAM ? ( + + ) : ( + + )} + + {config.rainType === ERainType.DOTPLOT ? ( + MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} + /> + ) : config.rainType === ERainType.BEESWARM ? ( + MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} + yPos={height / 2} + width={width} + height={height / 2} + config={config} + numCol={column} + circleCallback={circlesCallback} + /> + ) : config.rainType === ERainType.WHEATPLOT ? ( + MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} + /> + ) : config.rainType === ERainType.STRIPPLOT ? ( + MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} + /> + ) : null} + {circlesRendered} + {config.lightningType === ELightningType.MEAN_AND_DEV ? ( + + ) : config.lightningType === ELightningType.MEAN ? ( + + ) : config.lightningType === ELightningType.MEDIAN_AND_DEV ? ( + + ) : config.lightningType === ELightningType.BOXPLOT ? ( + + ) : null} + + + + + + ) : null} + + + + ); +} diff --git a/src/vis/raincloud/RaincloudCloudSelect.tsx b/src/vis/raincloud/RaincloudCloudSelect.tsx new file mode 100644 index 000000000..1f31c3c80 --- /dev/null +++ b/src/vis/raincloud/RaincloudCloudSelect.tsx @@ -0,0 +1,17 @@ +import { Select } from '@mantine/core'; +import * as React from 'react'; +import { ECloudType } from './interfaces'; + +interface HexbinOptionSelectProps { + callback: (c: ECloudType) => void; + currentSelected: ECloudType; +} + +export function RaincloudCloudSelect({ callback, currentSelected }: HexbinOptionSelectProps) { + const options = [ + { value: ECloudType.SPLIT_VIOLIN, label: ECloudType.SPLIT_VIOLIN }, + { value: ECloudType.HEATMAP, label: ECloudType.HEATMAP }, + { value: ECloudType.HISTOGRAM, label: ECloudType.HISTOGRAM }, + ]; + return callback(e as ELightningType)} data={options} value={currentSelected} />; +} diff --git a/src/vis/raincloud/RaincloudRainSelect.tsx b/src/vis/raincloud/RaincloudRainSelect.tsx new file mode 100644 index 000000000..cbba136a7 --- /dev/null +++ b/src/vis/raincloud/RaincloudRainSelect.tsx @@ -0,0 +1,18 @@ +import { Select } from '@mantine/core'; +import * as React from 'react'; +import { ERainType } from './interfaces'; + +interface HexbinOptionSelectProps { + callback: (c: ERainType) => void; + currentSelected: ERainType; +} + +export function RaincloudRainSelect({ callback, currentSelected }: HexbinOptionSelectProps) { + const options = [ + { value: ERainType.DOTPLOT, label: ERainType.DOTPLOT }, + { value: ERainType.BEESWARM, label: ERainType.BEESWARM }, + { value: ERainType.WHEATPLOT, label: ERainType.WHEATPLOT }, + { value: ERainType.STRIPPLOT, label: ERainType.STRIPPLOT }, + ]; + return callback(columns.find((c) => c.info.id === e)?.info)} name="numColumns" data={filteredColumnsByType} - value={currentSelected?.id} + value={currentSelected?.id || null} /> ); } diff --git a/src/vis/sidebar/ViolinOverlayButtons.tsx b/src/vis/sidebar/ViolinOverlayButtons.tsx deleted file mode 100644 index 0f5807842..000000000 --- a/src/vis/sidebar/ViolinOverlayButtons.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Container, SegmentedControl, Stack, Text } from '@mantine/core'; -import * as React from 'react'; -import { EViolinOverlay } from '../interfaces'; - -interface ViolinOverlayProps { - callback: (s: EViolinOverlay) => void; - currentSelected: EViolinOverlay; -} - -export function ViolinOverlayButtons({ callback, currentSelected }: ViolinOverlayProps) { - return ( - - - - Overlay - - - - - ); -} diff --git a/src/vis/sidebar/VisTypeSelect.tsx b/src/vis/sidebar/VisTypeSelect.tsx index c12691c81..8940d4556 100644 --- a/src/vis/sidebar/VisTypeSelect.tsx +++ b/src/vis/sidebar/VisTypeSelect.tsx @@ -1,24 +1,65 @@ -import { Select } from '@mantine/core'; +import { Select, Text } from '@mantine/core'; import * as React from 'react'; -import { allVisTypes, ESupportedPlotlyVis } from '../interfaces'; +import { useVisProvider } from '../Provider'; +import { ESupportedPlotlyVis } from '../interfaces'; +import { HelpHoverCard } from '../../components/HelpHoverCard'; interface VisTypeSelectProps { callback: (s: ESupportedPlotlyVis) => void; currentSelected: ESupportedPlotlyVis; } +interface ItemProps extends React.ComponentPropsWithoutRef<'div'> { + image: string; + label: string; + description: string; +} + +const SelectItem = React.forwardRef(({ image, label, description, ...others }: ItemProps, ref) => ( +
+ {label} + + {description} + +
+)); + +SelectItem.displayName = '@mantine/core/SelectItem'; + export function VisTypeSelect({ callback, currentSelected }: VisTypeSelectProps) { + const { visTypes, getVisByType } = useVisProvider(); + + const currentVis = getVisByType(currentSelected); + return (