diff --git a/.env.example b/.env.example index 3d287add..465915cc 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,11 @@ DB_PASS=postgres POSTGRES_DB=udav DB_SCHEMA=public DB_DIALECT=POSTGRES -# Batch size for database inserts (default: 5000) +# Batch size for database inserts (default: 10000) # Higher = fewer DB roundtrips, more memory. Range: 1000-15000 -DB_BATCH_SIZE=5000 +DB_BATCH_SIZE=10000 # Max identifier length (PostgreSQL: 63, MySQL: 64, MSSQL: 128) -DB_MAX_IDENT=255 +DB_MAX_IDENT=63 # ============================================ # DUUI Importer Configuration @@ -24,11 +24,19 @@ DUUI_IMPORTER=false # Path to input files DUUI_IMPORTER_PATH=/app/data/input # File extension: .xmi (uncompressed) or .gz (gzip compressed) -DUUI_IMPORTER_FILE_ENDING=.xmi +DUUI_IMPORTER_FILE_ENDING=.gz # Number of parallel workers (default: 4, rule: 1 per CPU core) DUUI_IMPORTER_WORKERS=4 -# UIMA CAS pool size (default: 2×workers) -DUUI_IMPORTER_CAS_POOL_SIZE=8 +# UIMA CAS pool size (default: 12) +DUUI_IMPORTER_CAS_POOL_SIZE=12 +# Number of DB writer workers +DUUI_IMPORTER_DB_WORKERS=4 +# Reader batch size (number of documents read per batch) +DUUI_IMPORTER_READER_BATCH_SIZE=8 +# Prepare DB schema on startup +DUUI_IMPORTER_PREPARE_DB_SCHEMA=true +# Store covered text of annotations +DUUI_IMPORTER_STORE_COVERED_TEXT=false # Optional: External TypeSystem XML file path (auto-detected from XMI if not set) DUUI_IMPORTER_TYPE_SYSTEM_PATH= diff --git a/.github/workflows/build-push-ghcr.yaml b/.github/workflows/build-push-ghcr.yaml deleted file mode 100644 index c7164c63..00000000 --- a/.github/workflows/build-push-ghcr.yaml +++ /dev/null @@ -1,48 +0,0 @@ -name: build-and-push - -on: - push: - branches: [ main ] - # Only rebuild when app/build-relevant files change. - # Do NOT rebuild when Flux only updates manifests under clusters/. - paths-ignore: - - 'clusters/**' - - '**/*.md' - workflow_dispatch: - -permissions: - contents: read - packages: write - -jobs: - build: - # Extra safety: skip commits created by Flux bot - if: github.actor != 'fluxcdbot' - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: docker/setup-buildx-action@v3 - - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Compute image tag - id: meta - shell: bash - run: | - ts=$(date -u +%Y%m%d%H%M%S) - echo "tag=${ts}-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: | - ghcr.io/texttechnologylab/unified-dynamic-annotation-visualizer:${{ steps.meta.outputs.tag }} - ghcr.io/texttechnologylab/unified-dynamic-annotation-visualizer:latest diff --git a/README.md b/README.md index 1e027e83..b6fa8199 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,28 @@ UDAV is designed to enable different disciplines to display their automatic pre- - Dynamic and interactive charts - Visual editor -- Different export options +- Different export options: svg, png, tex, csv, json +- Widget pagination +- LLM ChatBot + +### Widgets + +UDAV currently contains the following widgets: + +- Text (static) +- Image (static) +- Video (static) +- Inline Frame (static) +- Table +- Bar Chart +- Pie Chart +- Line Chart +- Highlight Text +- Simple Map +- Network Graph +- Voronoi Diagram +- Medial Axis +- Boundary Approximation ## Getting Started @@ -74,7 +95,7 @@ UDAV is designed to enable different disciplines to display their automatic pre- 4. Start the `App.java` file > [!NOTE] -> The webpage, by deafult, is reachable under: [http://localhost:8080](http://localhost:8080/). If you're looking for a small demo without creating it yourself, please check our [open demo](udav/demo). +> The webpage, by deafult, is reachable under: [http://localhost:8080](http://localhost:8080/). If you're looking for a small demo without creating it yourself, please check our [open demo](https://demo.udav.texttechnologylab.org/). ## License diff --git a/clusters/homelab/apps-kustomization.yaml b/clusters/homelab/apps-kustomization.yaml deleted file mode 100644 index a82e382f..00000000 --- a/clusters/homelab/apps-kustomization.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: apps - namespace: flux-system -spec: - interval: 5m - path: ./clusters/homelab - prune: true - wait: true - sourceRef: - kind: GitRepository - name: flux-system \ No newline at end of file diff --git a/clusters/homelab/flux-system/kustomization.yaml b/clusters/homelab/flux-system/kustomization.yaml deleted file mode 100644 index 1f54ecc7..00000000 --- a/clusters/homelab/flux-system/kustomization.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: -- ../apps-kustomization.yaml diff --git a/clusters/homelab/kustomization.yaml b/clusters/homelab/kustomization.yaml deleted file mode 100644 index d53f6f63..00000000 --- a/clusters/homelab/kustomization.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - namespace.yaml - - udav-source.yaml - - udav-configmap.yaml - - udav-deployment.yaml - - udav-image-automation.yaml diff --git a/clusters/homelab/namespace.yaml b/clusters/homelab/namespace.yaml deleted file mode 100644 index ae87ad85..00000000 --- a/clusters/homelab/namespace.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: uni - diff --git a/clusters/homelab/udav-configmap.yaml b/clusters/homelab/udav-configmap.yaml deleted file mode 100644 index d40472aa..00000000 --- a/clusters/homelab/udav-configmap.yaml +++ /dev/null @@ -1,27 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: udav-config - namespace: uni -data: - # Database configuration (non-sensitive) - # DB_URL is intentionally provided by Secret `udav-secrets` - DB_SCHEMA: "public" - DB_BATCH_SIZE: "5000" - DB_MAX_IDENT: "255" - DB_DIALECT: "POSTGRES" - - # DUUI Importer Configuration - DUUI_IMPORTER: "false" - DUUI_IMPORTER_PATH: "/app/data/input" - DUUI_IMPORTER_WORKERS: "4" - DUUI_IMPORTER_CAS_POOL_SIZE: "8" - - # Application Configuration - APP_INPUT_DIR: "/app/data/input" - SROUCE_BUILDER: "false" - PIPELINE_IMPORTER: "true" - PIPELINE_IMPORTER_FOLDER: "/app/pipelines" - - # JVM options - JAVA_OPTS: "-Xmx1024m -Xms512m" diff --git a/clusters/homelab/udav-deployment.yaml b/clusters/homelab/udav-deployment.yaml deleted file mode 100644 index 976595f9..00000000 --- a/clusters/homelab/udav-deployment.yaml +++ /dev/null @@ -1,91 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: udav - namespace: uni - labels: - app: udav -spec: - replicas: 1 - selector: - matchLabels: - app: udav - template: - metadata: - labels: - app: udav - spec: - imagePullSecrets: - - name: ghcr-secret - containers: - - name: app - image: ghcr.io/texttechnologylab/unified-dynamic-annotation-visualizer:latest # {"$imagepolicy": "flux-system:udav"} - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 8080 - envFrom: - - configMapRef: - name: udav-config - - secretRef: - name: udav-secrets - readinessProbe: - httpGet: - path: /actuator/health/readiness - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - livenessProbe: - httpGet: - path: /actuator/health/liveness - port: http - initialDelaySeconds: 60 - periodSeconds: 20 - resources: - requests: - memory: "512Mi" - cpu: "250m" - limits: - memory: "1.5Gi" - cpu: "1000m" ---- -apiVersion: v1 -kind: Service -metadata: - name: udav - namespace: uni - labels: - app: udav -spec: - selector: - app: udav - ports: - - name: http - port: 80 - targetPort: http - type: ClusterIP ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: udav-ingress - namespace: uni - annotations: - traefik.ingress.kubernetes.io/router.entrypoints: websecure -spec: - ingressClassName: traefik - tls: - - hosts: - - udav.example.com # TODO: replace with your actual hostname - secretName: udav-tls - rules: - - host: udav.example.com # TODO: replace with your actual hostname - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: udav - port: - name: http diff --git a/clusters/homelab/udav-image-automation.yaml b/clusters/homelab/udav-image-automation.yaml deleted file mode 100644 index b64ad8e0..00000000 --- a/clusters/homelab/udav-image-automation.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: image.toolkit.fluxcd.io/v1 -kind: ImageRepository -metadata: - name: udav - namespace: flux-system -spec: - image: ghcr.io/texttechnologylab/unified-dynamic-annotation-visualizer - interval: 1m - secretRef: - name: ghcr-credentials ---- -apiVersion: image.toolkit.fluxcd.io/v1 -kind: ImagePolicy -metadata: - name: udav - namespace: flux-system -spec: - imageRepositoryRef: - name: udav - filterTags: - # Match tags like: 20260226205739-ccfd3df13de72a194bb4be9bc34465dd4939ab6b - pattern: '^[0-9]{14}-[0-9a-z]{40}$' - policy: - alphabetical: - order: asc ---- -apiVersion: image.toolkit.fluxcd.io/v1 -kind: ImageUpdateAutomation -metadata: - name: udav - namespace: flux-system -spec: - interval: 5m - sourceRef: - kind: GitRepository - name: udav-repo - git: - checkout: - ref: - branch: main - commit: - author: - name: fluxcdbot - email: fluxcdbot@users.noreply.github.com - messageTemplate: "chore(udav): update image" - push: - branch: main - update: - path: ./clusters/homelab - strategy: Setters diff --git a/clusters/homelab/udav-source.yaml b/clusters/homelab/udav-source.yaml deleted file mode 100644 index 93361336..00000000 --- a/clusters/homelab/udav-source.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: source.toolkit.fluxcd.io/v1 -kind: GitRepository -metadata: - name: udav-repo - namespace: flux-system -spec: - interval: 1m - ref: - branch: main - secretRef: - name: udav-repo-auth - url: https://github.com/texttechnologylab/Unified-Dynamic-Annotation-Visualizer.git - diff --git a/docker-compose.yml b/docker-compose.yml index ec7c8890..4dd8f06d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,15 +7,22 @@ services: POSTGRES_DB: ${POSTGRES_DB:-udav} POSTGRES_USER: ${DB_USER:-postgres} POSTGRES_PASSWORD: ${DB_PASS:-postgres} + command: > + postgres + -c shared_buffers=8GB + -c maintenance_work_mem=2GB + -c work_mem=64MB + -c effective_cache_size=24GB + -c max_wal_size=8GB + -c checkpoint_timeout=15min + -c wal_compression=on + -c synchronous_commit=off + -c max_parallel_maintenance_workers=4 + shm_size: 1g ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${POSTGRES_DB:-udav}"] - interval: 10s - timeout: 5s - retries: 5 udav: build: diff --git a/pom.xml b/pom.xml index 862ee292..413648cc 100644 --- a/pom.xml +++ b/pom.xml @@ -175,7 +175,6 @@ org.postgresql postgresql 42.7.4 - runtime diff --git a/src/main/java/org/texttechnologylab/udav/api/Controller/ConvertionController.java b/src/main/java/org/texttechnologylab/udav/api/Controller/ConvertionController.java index 45497630..f52882a6 100644 --- a/src/main/java/org/texttechnologylab/udav/api/Controller/ConvertionController.java +++ b/src/main/java/org/texttechnologylab/udav/api/Controller/ConvertionController.java @@ -1,24 +1,61 @@ package org.texttechnologylab.udav.api.Controller; +import java.io.ByteArrayOutputStream; import java.util.HashMap; import java.util.Iterator; import java.util.Map; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpHeaders; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.texttechnologylab.udav.widgets.Widget; +import org.texttechnologylab.udav.widgets.jsontocsv.JsonToCsvConverter; import org.texttechnologylab.udav.widgets.svgtolatex.SvgToLaTeXConverter; @RestController @RequestMapping("/api/convertions") public class ConvertionController { + @PostMapping("/csv") + public ResponseEntity> widgetToCsv(@RequestBody String body) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + JsonNode node = mapper.readTree(body); + JsonNode jsonNodeJson = node.get("data"); + String widgetType = node.get("type").asText(); + + String csv; + try { + Widget widget = Widget.constructWidget(widgetType); + csv = widget.toCsv(node); + if (csv == null) throw new Exception(); + // widget-intrinsic native csv defined! + + } catch (Exception ignored) { + // No widget-intrinsic csv defined -> Use general JsonToCsvConverter + + JsonToCsvConverter converter = new JsonToCsvConverter(mapper); + csv = converter.convert(jsonNodeJson); + } + + Map response = new HashMap<>(); + response.put("content", csv); + + // TODO: Add metadata + + return ResponseEntity.ok(response); + } + @PostMapping("/tikz") public ResponseEntity> widgetToTikz(@RequestBody String body) throws Exception { // Parse JSON body to extract SVG string @@ -48,6 +85,37 @@ public ResponseEntity> widgetToTikz(@RequestBody String body return ResponseEntity.ok(response); } + @PostMapping("/zip") + public ResponseEntity createZip( + @RequestParam("files") List files) throws Exception { + + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) { + for (MultipartFile file : files) { + String filename = file.getOriginalFilename() != null + ? file.getOriginalFilename() + : "file_" + System.currentTimeMillis(); + + ZipEntry zipEntry = new ZipEntry(filename); + zipOutputStream.putNextEntry(zipEntry); + zipOutputStream.write(file.getBytes()); + zipOutputStream.closeEntry(); + } + } + + byte[] zipBytes = byteArrayOutputStream.toByteArray(); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"archive.zip\""); + headers.set(HttpHeaders.CONTENT_TYPE, "application/zip"); + headers.set(HttpHeaders.CONTENT_LENGTH, String.valueOf(zipBytes.length)); + + return ResponseEntity.ok() + .headers(headers) + .body(zipBytes); + } + private static String addMetaDataToTex(String tex, JsonNode node) { try { JsonNode metadataNode = node.path("meta").path("metadata"); diff --git a/src/main/java/org/texttechnologylab/udav/api/Controller/DataController.java b/src/main/java/org/texttechnologylab/udav/api/Controller/DataController.java index f6a5f73e..23b59d49 100644 --- a/src/main/java/org/texttechnologylab/udav/api/Controller/DataController.java +++ b/src/main/java/org/texttechnologylab/udav/api/Controller/DataController.java @@ -2,6 +2,7 @@ package org.texttechnologylab.udav.api.Controller; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; import lombok.Setter; import org.springframework.http.HttpHeaders; @@ -22,10 +23,12 @@ public class DataController { private final PipelineService pipelineService; private final DataService handler; + private final ObjectMapper mapper; - public DataController(PipelineService pipelineService, DataService handler) { + public DataController(PipelineService pipelineService, DataService handler, ObjectMapper mapper) { this.pipelineService = pipelineService; this.handler = handler; + this.mapper = mapper; } private static Map toStringMap(Map src) { @@ -106,6 +109,9 @@ public ResponseEntity postData( @RequestParam("pipelineId") String pipelineId, @RequestParam("generatorId") String generatorId, @RequestParam("chartType") String chartType, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size, + @RequestParam(value = "includeIds", required = false) Boolean includeIds, @RequestParam(value = "pretty", defaultValue = "false") boolean pretty, @RequestBody FilterEnvelope body ) throws Exception { @@ -113,11 +119,56 @@ public ResponseEntity postData( Map filterValues = toStringMap(body.chart()); Map corpusValues = toStringMap(body.corpus()); - String json = handler.buildArrayJson(generatorId, chartType, filterValues, corpusValues, pretty, pipelineId); + JsonNode node = handler.buildDataJson( + pipelineId, + generatorId, + chartType, + page, + size, + includeIds, + filterValues, + corpusValues + ); + String json = pretty + ? mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) + : mapper.writeValueAsString(node); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .body(json); } + @PostMapping(value = "/data/export") + public ResponseEntity downloadData( + @RequestParam("pipelineId") String pipelineId, + @RequestParam("generatorId") String generatorId, + @RequestParam("chartType") String chartType, + @RequestParam(value = "format", defaultValue = "json") String format, + @RequestBody(required = false) FilterEnvelope body + ) throws Exception { + Map filterValues = toStringMap(body == null ? null : body.chart()); + Map corpusValues = toStringMap(body == null ? null : body.corpus()); + + byte[] zip = handler.buildGroupArchive( + pipelineId, + generatorId, + chartType, + format, + filterValues, + corpusValues + ); + + String safeFormat = (format == null || format.isBlank()) ? "json" : format.toLowerCase(Locale.ROOT); + String filename = String.format( + Locale.ROOT, + "%s-%s-all.zip", + generatorId.replaceAll("[^a-zA-Z0-9._-]", "_"), + safeFormat.replaceAll("[^a-zA-Z0-9._-]", "_") + ); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_TYPE, "application/zip") + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .body(zip); + } /** * Envelope DTO for the posted filters. diff --git a/src/main/java/org/texttechnologylab/udav/api/Controller/PipelineController.java b/src/main/java/org/texttechnologylab/udav/api/Controller/PipelineController.java index 18bc9880..5182f429 100644 --- a/src/main/java/org/texttechnologylab/udav/api/Controller/PipelineController.java +++ b/src/main/java/org/texttechnologylab/udav/api/Controller/PipelineController.java @@ -1,18 +1,22 @@ package org.texttechnologylab.udav.api.Controller; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.texttechnologylab.udav.api.service.PipelineService; import javax.validation.Valid; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/pipelines") public class PipelineController { private final PipelineService service; + private final ObjectMapper mapper = new ObjectMapper(); public PipelineController(PipelineService service) { this.service = service; @@ -20,18 +24,22 @@ public PipelineController(PipelineService service) { // List names with optional search + pagination @GetMapping - public ResponseEntity> list( + public ResponseEntity>> list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "100") int size, - @RequestParam(required = false) String q - ) throws Exception { - return ResponseEntity.ok(service.listIds(page, size, q)); + @RequestParam(required = false) String q) throws Exception { + return ResponseEntity.ok(service.listSummaries(page, size, q)); } // Get full JSON by name @GetMapping("/{id}") - public ResponseEntity get(@PathVariable String id) throws Exception { - JsonNode json = service.get(id); + public ResponseEntity get( + @PathVariable String id, + @RequestParam(name = "pretty", defaultValue = "false") boolean pretty) throws Exception { + JsonNode node = service.get(id); + String json = pretty + ? mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) + : mapper.writeValueAsString(node); return ResponseEntity.ok(json); } diff --git a/src/main/java/org/texttechnologylab/udav/api/Repositories/GeneratorDataRepository.java b/src/main/java/org/texttechnologylab/udav/api/Repositories/GeneratorDataRepository.java index f388f510..45fec95e 100644 --- a/src/main/java/org/texttechnologylab/udav/api/Repositories/GeneratorDataRepository.java +++ b/src/main/java/org/texttechnologylab/udav/api/Repositories/GeneratorDataRepository.java @@ -69,6 +69,25 @@ private Field F_TYPE(String schema, String table) { return field(name(schema, table, DBConstants.TABLEATTR_GENERATORDATA_TYPE), String.class); } + private Table T_GENERATOR_TYPE(String schema) { + return table(name(schema, DBConstants.TABLENAME_GENERATORTYPE)); + } + + /** + * Resolve generator implementation type (simple class name) from GENERATORTYPE table. + */ + public Optional loadGeneratorType(String schema, String generatorId) { + var T = T_GENERATOR_TYPE(schema); + var GEN = field(name(schema, DBConstants.TABLENAME_GENERATORTYPE, DBConstants.TABLEATTR_GENERATORID), String.class); + var TYPE = field(name(schema, DBConstants.TABLENAME_GENERATORTYPE, DBConstants.TABLEATTR_GENERATORTYPE), String.class); + + return dsl.select(TYPE) + .from(T) + .where(GEN.eq(generatorId)) + .limit(1) + .fetchOptional(TYPE); + } + // ---------- CategoryNumber data ---------- /** @@ -138,6 +157,7 @@ public Optional loadText(String schema, String generatorId) { return dsl.select(F_TEXT) .from(T) .where(GEN.eq(generatorId)) + .limit(1) .fetchOptional(F_TEXT); } @@ -304,6 +324,38 @@ record -> new MapCoordinatesRow( ); } + public Map> loadMapCoordinatesEdgesByFile(String schema, String generatorId) { + + Table TABLE = DSL.table(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES)); + + Field GENERATORID = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, DBConstants.TABLEATTR_GENERATORID), String.class); + Field FILENAME = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, DBConstants.TABLEATTR_FILENAME), String.class); + Field EDGE_FROM = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, DBConstants.TABLEATTR_GENERATORDATA_EDGE_FROM), Integer.class); + Field EDGE_TO = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, DBConstants.TABLEATTR_GENERATORDATA_EDGE_TO), Integer.class); + Field EDGE_NUMBER = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, DBConstants.TABLEATTR_GENERATORDATA_EDGE_NUMBER), Double.class); + Field LABEL = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, DBConstants.TABLEATTR_GENERATORDATA_LABEL), String.class); + Field COLOR = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, DBConstants.TABLEATTR_GENERATORDATA_COLOR_FILL), String.class); + + try { + return dsl.select(FILENAME, EDGE_FROM, EDGE_TO, EDGE_NUMBER, LABEL, COLOR) + .from(TABLE) + .where(GENERATORID.eq(generatorId)) + .fetchGroups( + record -> record.get(FILENAME), + record -> new MapCoordinatesEdgeRow( + record.get(EDGE_FROM) != null ? record.get(EDGE_FROM) : -1, + record.get(EDGE_TO) != null ? record.get(EDGE_TO) : -1, + record.get(EDGE_NUMBER), + record.get(LABEL), + record.get(COLOR) + ) + ); + } catch (Exception ignored) { + // Edge table is optional and may not exist (or may not be visible yet) in some schemas. + return Collections.emptyMap(); + } + } + // Helper method to convert your stored string back to a List private static List coordinatesStringToList(String coordinatesStr) { if (coordinatesStr == null || coordinatesStr.isEmpty()) return Collections.emptyList(); @@ -320,4 +372,6 @@ public record ResultCategoryNumber(Map values, Map coordinates, double scale, String fillColor, String strokeColor, String outsideColor) {} + + public record MapCoordinatesEdgeRow(int fromIndex, int toIndex, Double number, String label, String color) {} } diff --git a/src/main/java/org/texttechnologylab/udav/api/Repositories/UIMATypeRepository.java b/src/main/java/org/texttechnologylab/udav/api/Repositories/UIMATypeRepository.java index 8a2db443..3b19b9de 100644 --- a/src/main/java/org/texttechnologylab/udav/api/Repositories/UIMATypeRepository.java +++ b/src/main/java/org/texttechnologylab/udav/api/Repositories/UIMATypeRepository.java @@ -2,9 +2,11 @@ import org.jooq.DSLContext; import org.jooq.impl.DSL; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import org.texttechnologylab.udav.api.dto.UimaTypeRow; +import org.texttechnologylab.udav.db.SchemaObjectNames; import java.util.List; @@ -13,6 +15,9 @@ public class UIMATypeRepository { private final DSLContext dsl; + @Value("${app.db.schema:public}") + private String schema; + public UIMATypeRepository(DSLContext dsl) { this.dsl = dsl; } @@ -23,12 +28,12 @@ public List list(int page, int size, String q) { int p = Math.max(0, page); int s = Math.max(1, size); - var REG = DSL.table("uima_type_registry"); - var JSON = DSL.table("json_data"); + var REG = DSL.table(DSL.name(schema, "uima_type_registry")); + var JSON = DSL.table(DSL.name(schema, SchemaObjectNames.TABLE_JSON_DATA)); - var F_URI = DSL.field("uima_type_uri", String.class); - var F_SRC = DSL.field("sourcefile_name", String.class); - var F_CNT = DSL.field("row_count", Long.class); + var F_URI = DSL.field(DSL.name("uima_type_uri"), String.class); + var F_SRC = DSL.field(DSL.name(SchemaObjectNames.COL_JSON_DATA_SOURCEFILE_NAME), String.class); + var F_CNT = DSL.field(DSL.name("row_count"), Long.class); var condRegistry = (q == null || q.isBlank()) ? DSL.noCondition() @@ -70,4 +75,4 @@ public List list(int page, int size, String q) { .limit(s) .fetchInto(UimaTypeRow.class); } -} \ No newline at end of file +} diff --git a/src/main/java/org/texttechnologylab/udav/api/service/DataService.java b/src/main/java/org/texttechnologylab/udav/api/service/DataService.java index f2ae37d8..93dfd5f8 100644 --- a/src/main/java/org/texttechnologylab/udav/api/service/DataService.java +++ b/src/main/java/org/texttechnologylab/udav/api/service/DataService.java @@ -2,13 +2,25 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.springframework.stereotype.Service; import org.texttechnologylab.udav.api.DummyDataProvider; import org.texttechnologylab.udav.api.ValueMode; import org.texttechnologylab.udav.api.charts.ChartRegistry; +import org.texttechnologylab.udav.generators.sources.SourceJsonN; +import org.texttechnologylab.udav.pipeline.Pipeline; +import org.texttechnologylab.udav.sources.DBAccess; +import org.texttechnologylab.udav.widgets.Widget; +import org.texttechnologylab.udav.widgets.jsontocsv.JsonToCsvConverter; +import javax.sql.DataSource; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; import java.util.*; import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; @Service public class DataService { @@ -16,13 +28,19 @@ public class DataService { private final ObjectMapper mapper; private final DummyDataProvider provider; private final ChartRegistry charts; + private final PipelineService pipelineService; + private final DataSource dataSource; public DataService(ObjectMapper mapper, DummyDataProvider provider, - ChartRegistry charts) { + ChartRegistry charts, + PipelineService pipelineService, + DataSource dataSource) { this.mapper = mapper; this.provider = provider; this.charts = charts; + this.pipelineService = pipelineService; + this.dataSource = dataSource; } public String buildArrayJson(String id, String type, @@ -30,30 +48,452 @@ public String buildArrayJson(String id, String type, Map corpus, boolean pretty, String schema) { + JsonNode node = ensureDatasetList(renderNode(id, type, filters, corpus, schema)); + try { + return pretty ? mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) + : mapper.writeValueAsString(node); + } catch (Exception e) { + return "[]"; + } + } + + public JsonNode buildDataJson(String pipelineId, + String generatorId, + String chartType, + Integer page, + Integer size, + Boolean includeIds, + Map filters, + Map corpus) throws Exception { + boolean isTemplate = isTemplateGeneratorId(generatorId); + + int requestedPage = page == null ? 0 : page; + int requestedSize = Math.max(1, size == null ? 1 : size); + + GroupResolution group = isTemplate + ? resolveGroupInternal(pipelineId, generatorId) + : new GroupResolution(Collections.singletonList(generatorId)); + + List ids = group.ids().stream().filter(id -> id != null && !id.isBlank()).toList(); + ObjectNode out = mapper.createObjectNode(); + ObjectNode meta = out.putObject("meta"); + meta.put("total", ids.size()); + meta.put("pageSize", requestedSize); + + if (ids.isEmpty()) { + meta.put("page", 0); + meta.set("ids", mapper.createArrayNode()); + out.set("data", mapper.createArrayNode()); + return out; + } + + int maxPage = (ids.size() - 1) / requestedSize; + int clampedPage = Math.max(0, Math.min(requestedPage, maxPage)); + int from = clampedPage * requestedSize; + int to = Math.min(ids.size(), from + requestedSize); + List pageIds = ids.subList(from, to); + + meta.put("page", clampedPage); + + ArrayNode idsNode = meta.putArray("ids"); + if (includeIds == null || includeIds) { + for (String id : ids) { + idsNode.add(id); + } + } + + if (requestedSize == 1) { + out.set("data", ensureDatasetList(renderNode(pageIds.get(0), chartType, filters, corpus, pipelineId))); + return out; + } + + ArrayNode data = out.putArray("data"); + for (String id : pageIds) { + ObjectNode itemNode = data.addObject(); + itemNode.set("data", ensureDatasetList(renderNode(id, chartType, filters, corpus, pipelineId))); + } + return out; + } + + public ObjectNode resolveGroup(String pipelineId, String templateGeneratorId, String chartType) throws Exception { + GroupResolution group = resolveGroupInternal(pipelineId, templateGeneratorId); + ObjectNode out = mapper.createObjectNode(); + out.put("pipelineId", pipelineId); + out.put("templateGeneratorId", templateGeneratorId); + out.put("chartType", chartType); + out.put("total", group.ids().size()); + ArrayNode idsNode = out.putArray("ids"); + for (String id : group.ids()) { + idsNode.add(id); + } + out.put("order", "DETERMINISTIC_SOURCE_ITERATION"); + return out; + } + + public ObjectNode groupItemByPage(String pipelineId, + String templateGeneratorId, + String chartType, + int page, + Map filters, + Map corpus) throws Exception { + GroupResolution group = resolveGroupInternal(pipelineId, templateGeneratorId); + + ObjectNode out = mapper.createObjectNode(); + ObjectNode meta = out.putObject("meta"); + meta.put("pipelineId", pipelineId); + meta.put("templateGeneratorId", templateGeneratorId); + meta.put("chartType", chartType); + meta.put("total", group.ids().size()); + meta.put("pageSize", 1); + + if (group.ids().isEmpty()) { + meta.put("page", 0); + meta.putNull("generatorId"); + meta.put("hasPrev", false); + meta.put("hasNext", false); + out.set("data", mapper.createArrayNode()); + return out; + } + + int clampedPage = Math.max(0, Math.min(page, group.ids().size() - 1)); + String generatorId = group.ids().get(clampedPage); + JsonNode data = renderNode(generatorId, chartType, filters, corpus, pipelineId); + + meta.put("page", clampedPage); + meta.put("generatorId", generatorId); + meta.put("hasPrev", clampedPage > 0); + meta.put("hasNext", clampedPage < group.ids().size() - 1); + out.set("data", ensureDatasetList(data)); + return out; + } + + public ObjectNode groupItemById(String pipelineId, + String templateGeneratorId, + String chartType, + String generatorId, + Map filters, + Map corpus) throws Exception { + GroupResolution group = resolveGroupInternal(pipelineId, templateGeneratorId); + + ObjectNode out = mapper.createObjectNode(); + ObjectNode meta = out.putObject("meta"); + meta.put("pipelineId", pipelineId); + meta.put("templateGeneratorId", templateGeneratorId); + meta.put("chartType", chartType); + meta.put("total", group.ids().size()); + meta.put("pageSize", 1); + + if (group.ids().isEmpty()) { + meta.put("page", 0); + meta.putNull("generatorId"); + meta.put("hasPrev", false); + meta.put("hasNext", false); + out.set("data", mapper.createArrayNode()); + return out; + } + + int page = group.ids().indexOf(generatorId); + if (page < 0) { + meta.put("page", 0); + meta.put("generatorId", generatorId); + meta.put("hasPrev", false); + meta.put("hasNext", false); + out.set("data", mapper.createArrayNode()); + return out; + } + + JsonNode data = renderNode(generatorId, chartType, filters, corpus, pipelineId); + meta.put("page", page); + meta.put("generatorId", generatorId); + meta.put("hasPrev", page > 0); + meta.put("hasNext", page < group.ids().size() - 1); + out.set("data", ensureDatasetList(data)); + return out; + } + + public byte[] buildGroupArchive(String pipelineId, + String templateGeneratorId, + String chartType, + String format, + Map filters, + Map corpus) throws Exception { + GroupResolution group = isTemplateGeneratorId(templateGeneratorId) + ? resolveGroupInternal(pipelineId, templateGeneratorId) + : new GroupResolution(Collections.singletonList(templateGeneratorId)); + String normalizedFormat = normalizeExportFormat(format); + + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(byteStream, StandardCharsets.UTF_8)) { + int index = 0; + for (String generatorId : group.ids()) { + JsonNode data = renderNode(generatorId, chartType, filters, corpus, pipelineId); + byte[] content = renderExportContent(normalizedFormat, chartType, data, filters, corpus); + String entryName = String.format( + Locale.ROOT, + "%03d-%s.%s", + index, + sanitizeFilenamePart(generatorId), + normalizedFormat + ); + + ZipEntry entry = new ZipEntry(entryName); + zip.putNextEntry(entry); + zip.write(content); + zip.closeEntry(); + index++; + } + } + + return byteStream.toByteArray(); + } + + private JsonNode renderNode(String id, String type, + Map filters, + Map corpus, + String schema) { Set files = Optional.ofNullable(corpus) .map(m -> m.get("files")) .map(Parsing::parseCsvSet) .orElseGet(Collections::emptySet); - ValueMode vm = ValueMode.from(filters.get("valueMode")); - filters.remove("valueMode"); + Map mutableFilters = new LinkedHashMap<>(Optional.ofNullable(filters).orElseGet(Collections::emptyMap)); + ValueMode vm = ValueMode.from(mutableFilters.get("valueMode")); + mutableFilters.remove("valueMode"); // Prefer handler if present if (charts.has(type)) { - JsonNode node = charts.get(type).render(id, filters, files, vm, schema); - try { - return pretty ? mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) - : mapper.writeValueAsString(node); - } catch (Exception e) { - return "[]"; - } + return charts.get(type).render(id, mutableFilters, files, vm, schema); } // fallback to your legacy provider paths if no handler found - return provider.getJsonFor(id, type); + try { + return mapper.readTree(provider.getJsonFor(id, type)); + } catch (Exception ignored) { + return mapper.createArrayNode(); + } + } + + private GroupResolution resolveGroupInternal(String pipelineId, String templateGeneratorId) throws Exception { + JsonNode pipeline = pipelineService.get(pipelineId); + JsonNode sources = pipeline.path("sources"); + if (sources.isArray()) { + for (JsonNode sourceNode : sources) { + JsonNode creates = sourceNode.path("createsGenerators"); + if (!creates.isArray()) { + continue; + } + + for (JsonNode generatorNode : creates) { + String generatorId = textOrNull(generatorNode.get("id")); + if (!Objects.equals(generatorId, templateGeneratorId)) { + continue; + } + + if (!isGeneratorGroup(generatorNode)) { + return new GroupResolution(Collections.singletonList(templateGeneratorId)); + } + + List subSourceIds = loadSubSourceIds(pipelineId, sourceNode); + return new GroupResolution(expandIds(templateGeneratorId, subSourceIds)); + } + } + } + + // Backward compatibility: support legacy top-level generators array. + JsonNode topLevelGenerators = pipeline.path("generators"); + if (topLevelGenerators.isArray() && sources.isArray()) { + for (JsonNode generatorNode : topLevelGenerators) { + String generatorId = textOrNull(generatorNode.get("id")); + if (!Objects.equals(generatorId, templateGeneratorId)) { + continue; + } + + String rawSource = textOrNull(generatorNode.get("source")); + if (!isGeneratorGroup(generatorNode)) { + return new GroupResolution(Collections.singletonList(templateGeneratorId)); + } + + String sourceRef = Pipeline.stripNSuffix(rawSource); + JsonNode sourceNode = findSourceById(sources, sourceRef); + if (sourceNode == null) { + return new GroupResolution(Collections.emptyList()); + } + List subSourceIds = loadSubSourceIds(pipelineId, sourceNode); + return new GroupResolution(expandIds(templateGeneratorId, subSourceIds)); + } + } + + return new GroupResolution(Collections.emptyList()); + } + + private byte[] renderExportContent(String format, + String chartType, + JsonNode data, + Map filters, + Map corpus) { + try { + ObjectNode payload = mapper.createObjectNode(); + payload.put("type", chartType); + payload.set("data", data); + ObjectNode meta = payload.putObject("meta"); + ObjectNode metadata = meta.putObject("metadata"); + if (corpus != null) { + corpus.forEach((k, v) -> metadata.put(k, v)); + } + if (filters != null) { + filters.forEach((k, v) -> metadata.put(k, v)); + } + + if ("json".equals(format)) { + ObjectNode out = mapper.createObjectNode(); + out.set("metadata", metadata.deepCopy()); + out.set("data", data); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(out); + } + + if ("csv".equals(format)) { + String csv; + try { + Widget widget = Widget.constructWidget(chartType); + csv = widget.toCsv(payload); + if (csv == null) throw new IllegalStateException("Null widget CSV"); + } catch (Exception ignored) { + JsonToCsvConverter converter = new JsonToCsvConverter(mapper); + csv = converter.convert(data); + } + return csv.getBytes(StandardCharsets.UTF_8); + } + + if ("tex".equals(format)) { + String tex; + try { + Widget widget = Widget.constructWidget(chartType); + tex = widget.toTex(payload); + if (tex == null) throw new IllegalStateException("Null widget TEX"); + } catch (Exception ignored) { + tex = "% TEX export is not available for this widget without SVG source.\n"; + } + return tex.getBytes(StandardCharsets.UTF_8); + } + } catch (Exception ignored) { + // Fallback to empty payload if one dataset fails to serialize. + } + + return new byte[0]; + } + + private static String normalizeExportFormat(String format) { + String normalized = Optional.ofNullable(format).orElse("json").trim().toLowerCase(Locale.ROOT); + if (normalized.equals("json") || normalized.equals("csv") || normalized.equals("tex")) { + return normalized; + } + throw new IllegalArgumentException("Unsupported bulk export format: " + format); + } + + private static String sanitizeFilenamePart(String input) { + if (input == null || input.isBlank()) { + return "dataset"; + } + return input.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + private JsonNode findSourceById(JsonNode sources, String sourceId) { + if (!sources.isArray() || sourceId == null) { + return null; + } + for (JsonNode sourceNode : sources) { + String currentId = textOrNull(sourceNode.get("id")); + if (Objects.equals(sourceId, currentId)) { + return sourceNode; + } + } + return null; } + private List loadSubSourceIds(String pipelineId, JsonNode sourceNode) { + String uri = textOrNull(sourceNode.get("uri")); + String normalized = Pipeline.stripNSuffix(uri); + if (!isDbJsonBackedSource(normalized)) { + return Collections.emptyList(); + } + + try { + SourceJsonN sourceJsonN = new SourceJsonN(normalized, new DBAccess(dataSource, pipelineId)); + return new ArrayList<>(sourceJsonN.getSubSourcesIdToObjectMap().keySet()); + } catch (Exception ignored) { + return Collections.emptyList(); + } + } + + private List expandIds(String idTemplate, List subSourceIds) { + List ids = new ArrayList<>(); + Set usedIds = new LinkedHashSet<>(); + int fallbackIndex = 0; + + for (String subSourceId : subSourceIds) { + String safeSubSourceId = (subSourceId == null || subSourceId.isBlank()) + ? Integer.toString(fallbackIndex) + : subSourceId; + + String candidate = idTemplate.contains("@ID@") + ? idTemplate.replace("@ID@", safeSubSourceId) + : idTemplate + "_" + safeSubSourceId; + + if (usedIds.contains(candidate)) { + int suffix = 0; + String fallback; + do { + fallback = idTemplate.contains("@ID@") + ? idTemplate.replace("@ID@", Integer.toString(suffix)) + : idTemplate + "_" + suffix; + suffix++; + } while (usedIds.contains(fallback)); + candidate = fallback; + } + + usedIds.add(candidate); + ids.add(candidate); + fallbackIndex++; + } + + return ids; + } + + private static String textOrNull(JsonNode node) { + if (node == null || node.isNull()) return null; + String text = node.asText(null); + if (text == null) return null; + String trimmed = text.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static boolean isDbJsonBackedSource(String definition) { + if (definition == null) return false; + String normalized = definition.trim().toUpperCase(Locale.ROOT); + return normalized.endsWith(".JSON") || normalized.endsWith(".XML"); + } + + private static boolean isTemplateGeneratorId(String generatorId) { + return generatorId != null && generatorId.contains("@ID@"); + } + + private static boolean isGeneratorGroup(JsonNode generatorNode) { + return generatorNode != null && generatorNode.path("generatorGroup").asBoolean(false); + } + + private JsonNode ensureDatasetList(JsonNode node) { + if (node == null || node.isNull() || node.isMissingNode()) { + return mapper.createArrayNode(); + } + // API contract: data is always a list of datasets, independent of widget payload shape. + ArrayNode wrapped = mapper.createArrayNode(); + wrapped.add(node); + return wrapped; + } + + private record GroupResolution(List ids) {} + // -------- parsing utils used by legacy filters -------- static final class Parsing { static Set parseCsvSet(String csv) { diff --git a/src/main/java/org/texttechnologylab/udav/api/service/PipelineService.java b/src/main/java/org/texttechnologylab/udav/api/service/PipelineService.java index c87ee29c..8e35dc4a 100644 --- a/src/main/java/org/texttechnologylab/udav/api/service/PipelineService.java +++ b/src/main/java/org/texttechnologylab/udav/api/service/PipelineService.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; import org.jooq.DSLContext; import org.jooq.impl.DSL; import org.jooq.impl.SQLDataType; @@ -11,31 +12,32 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import org.texttechnologylab.udav.db.SchemaObjectNames; +import org.texttechnologylab.udav.api.service.utils.GeneratorConverter; -import jakarta.annotation.PostConstruct; import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import static org.springframework.http.HttpStatus.*; @Service public class PipelineService { - private static final String TABLE = "pipeline"; - private static final String COL_ID = "pipeline_id"; - private static final String COL_NAME = "pipeline_name"; - private static final String COL_JSON = "json"; + private static final String TABLE = SchemaObjectNames.TABLE_PIPELINE; + private static final String COL_ID = SchemaObjectNames.COL_PIPELINE_ID; + private static final String COL_NAME = SchemaObjectNames.COL_PIPELINE_NAME; + private static final String COL_JSON = SchemaObjectNames.COL_PIPELINE_JSON; private final SourceBuildService sourceBuildService; private final DataSource dataSource; private final ObjectMapper objectMapper; - + Logger LOGGER = LoggerFactory.getLogger(PipelineService.class); @Value("${app.db.schema:public}") private String schema; - Logger LOGGER = LoggerFactory.getLogger(PipelineService.class); - public PipelineService(SourceBuildService sourceBuildService, DataSource dataSource, ObjectMapper objectMapper) { this.sourceBuildService = sourceBuildService; this.dataSource = dataSource; @@ -56,20 +58,51 @@ void ensureTable() throws Exception { } } + @Transactional(readOnly = true) + public List listAllIds() throws Exception { + try (Connection c = dataSource.getConnection()) { + DSLContext dsl = DSL.using(c); + var fieldId = DSL.field(DSL.name(COL_ID), String.class); + return dsl.select(fieldId) + .from(DSL.table(DSL.name(schema, TABLE))) + .orderBy(fieldId.asc()) + .fetch(fieldId); + } + } + @Transactional(readOnly = true) public List listIds(int page, int size, String q) throws Exception { + return listSummaries(page, size, q).stream() + .map(summary -> summary.get("id")) + .toList(); + } + + @Transactional(readOnly = true) + public List> listSummaries(int page, int size, String q) throws Exception { try (Connection c = dataSource.getConnection()) { DSLContext dsl = DSL.using(c); + var fieldId = DSL.field(DSL.name(COL_ID), String.class); + var fieldName = DSL.field(DSL.name(COL_NAME), String.class); + var fieldJson = DSL.field(DSL.name(COL_JSON), String.class); var cond = (q == null || q.isBlank()) ? DSL.noCondition() - : DSL.field(COL_ID, String.class).likeIgnoreCase("%" + q + "%"); - return dsl.select(DSL.field(COL_ID, String.class)) - .from(DSL.table(TABLE)) + : fieldId.likeIgnoreCase("%" + q + "%") + .or(fieldName.likeIgnoreCase("%" + q + "%")); + return dsl.select(fieldId, fieldName, fieldJson) + .from(DSL.table(DSL.name(schema, TABLE))) .where(cond) - .orderBy(DSL.field(COL_ID).asc()) + .orderBy(fieldName.asc()) .offset(Math.max(0, page) * Math.max(1, size)) .limit(Math.max(1, size)) - .fetchInto(String.class); + .fetch(record -> { + String id = record.get(fieldId); + String name = record.get(fieldName); + + Map summary = new LinkedHashMap<>(); + summary.put("id", id); + summary.put("name", name); + return summary; + }); } } @@ -77,20 +110,22 @@ public List listIds(int page, int size, String q) throws Exception { public JsonNode get(String id) throws Exception { try (Connection c = dataSource.getConnection()) { DSLContext dsl = DSL.using(c); - String json = dsl.select(DSL.field(COL_JSON, String.class)) - .from(DSL.table(TABLE)) - .where(DSL.field(COL_ID).eq(id)) + String json = dsl.select(DSL.field(DSL.name(COL_JSON), String.class)) + .from(DSL.table(DSL.name(schema, TABLE))) + .where(DSL.field(DSL.name(COL_ID)).eq(id)) + .orderBy(DSL.field(DSL.name(COL_NAME)).asc()) .fetchOneInto(String.class); if (json == null) throw new ResponseStatusException(NOT_FOUND, "Pipeline not found"); - return parseJson(json); + String normalized = GeneratorConverter.toNewFormat(json); + return parseJson(normalized); } } @Transactional public String create(JsonNode json) throws Exception { - String id = json.get("id").asText("main"); - + String id = json.get("id").asText(); + String name = json.get("name").asText(); String jsonStr = toString(json); try (Connection c = dataSource.getConnection()) { @@ -98,16 +133,16 @@ public String create(JsonNode json) throws Exception { // check exists boolean exists = dsl.fetchExists( dsl.selectOne() - .from(DSL.table(TABLE)) - .where(DSL.field(COL_ID).eq(id)) + .from(DSL.table(DSL.name(schema, TABLE))) + .where(DSL.field(DSL.name(COL_ID)).eq(id)) ); if (exists) throw new ResponseStatusException(CONFLICT, "Pipeline already exists"); - dsl.insertInto(DSL.table(TABLE), - DSL.field(COL_ID), - DSL.field(COL_NAME), - DSL.field(COL_JSON)) - .values(id, id, jsonStr) + dsl.insertInto(DSL.table(DSL.name(schema, TABLE)), + DSL.field(DSL.name(COL_ID)), + DSL.field(DSL.name(COL_NAME)), + DSL.field(DSL.name(COL_JSON))) + .values(id, name, jsonStr) .execute(); sourceBuildService.startBuild(id, id); @@ -120,20 +155,28 @@ public String create(JsonNode json) throws Exception { @Transactional public void update(JsonNode json) throws Exception { - String id = json.get("id").asText(null); + String id = json.get("id").asText(); if (id == null || id.isBlank()) { throw new ResponseStatusException(BAD_REQUEST, "Missing or empty pipeline id"); } + + String name = json.get("name").asText(); String jsonStr = toString(json); + try (Connection c = dataSource.getConnection()) { DSLContext dsl = DSL.using(c); - int updated = dsl.update(DSL.table(TABLE)) - .set(DSL.field(COL_JSON), jsonStr) - .where(DSL.field(COL_ID).eq(id)) + + int updated = dsl.update(DSL.table(DSL.name(schema, TABLE))) + .set(DSL.field(DSL.name(COL_NAME)), name) + .set(DSL.field(DSL.name(COL_JSON)), jsonStr) + .where(DSL.field(DSL.name(COL_ID)).eq(id)) .execute(); - if (updated == 0) throw new ResponseStatusException(NOT_FOUND, "Pipeline not found"); - sourceBuildService.startBuild(id, id); + if (updated == 0) { + throw new ResponseStatusException(NOT_FOUND, "Pipeline not found"); + } + + sourceBuildService.startBuild(id, id); LOGGER.info("Updated pipeline: {}", id); } } @@ -144,8 +187,8 @@ public void delete(String id) { DSLContext dsl = DSL.using(c); // 1) Delete the pipeline row - int deleted = dsl.deleteFrom(DSL.table(TABLE)) - .where(DSL.field(COL_ID).eq(id)) + int deleted = dsl.deleteFrom(DSL.table(DSL.name(schema, TABLE))) + .where(DSL.field(DSL.name(COL_ID)).eq(id)) .execute(); if (deleted == 0) { throw new ResponseStatusException(NOT_FOUND, "Pipeline not found"); @@ -182,4 +225,5 @@ private String toString(JsonNode json) { throw new ResponseStatusException(BAD_REQUEST, "Invalid JSON"); } } + } diff --git a/src/main/java/org/texttechnologylab/udav/api/service/SourceBuildService.java b/src/main/java/org/texttechnologylab/udav/api/service/SourceBuildService.java index f87adc44..b4c05954 100644 --- a/src/main/java/org/texttechnologylab/udav/api/service/SourceBuildService.java +++ b/src/main/java/org/texttechnologylab/udav/api/service/SourceBuildService.java @@ -1,15 +1,20 @@ package org.texttechnologylab.udav.api.service; import lombok.RequiredArgsConstructor; +import org.jooq.DSLContext; +import org.jooq.impl.DSL; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; +import org.texttechnologylab.udav.database.DBConstants; import org.texttechnologylab.udav.pipeline.Pipeline; import org.texttechnologylab.udav.sources.DBAccess; import org.texttechnologylab.udav.sources.SourceBuildOps; import javax.sql.DataSource; +import java.sql.Connection; import java.util.ArrayList; import java.util.Collection; @@ -21,16 +26,19 @@ public class SourceBuildService { private final DataSource dataSource; private final SourceBuildOps ops; + @Value("${app.db.schema:public}") + private String appDbSchema; + /** * Build all sources for a given schema + pipeline. * This version runs synchronously and is not concurrency-guarded. */ - public void startBuild(String schema, @Nullable String pipelineId) { + public void startBuild(String schema, @Nullable String pipelineId) { //TODO: remove duplicate unnecessary call try { doBuild(schema, pipelineId); } catch (Exception e) { - logger.error(e.getMessage()); - logger.warn("Build failed for pipeline={}", pipelineId); + logger.error("Build failed for pipeline={}: {}", pipelineId, e.getMessage(), e); + throw new RuntimeException("Build failed for pipeline=" + pipelineId, e); } } @@ -38,20 +46,125 @@ private void doBuild(String schema, @Nullable String pipelineId) throws Exceptio if (pipelineId == null || pipelineId.isBlank()) { pipelineId = "main"; } - DBAccess dbAccess = new DBAccess(dataSource, schema); + String targetSchema = normalizeSchemaName(schema); + String tempSchema = targetSchema + "__tmp"; + String oldSchema = targetSchema + "__old"; + + assertSafePipelineSchema(targetSchema); + assertSafeAuxSchema(tempSchema); + assertSafeAuxSchema(oldSchema); + + cleanupTransientSchemas(tempSchema, oldSchema); - // Load pipeline from DB - Pipeline pipeline = Pipeline.fromDB(dbAccess, pipelineId); + // The pipeline row lives in app.db.schema; generator data is built in temp and then swapped into the target schema. + DBAccess readAccess = new DBAccess(dataSource, appDbSchema); + DBAccess writeAccess = new DBAccess(dataSource, tempSchema); + + // Load pipeline JSON from the shared schema, build generators with the pipeline's own schema. + Pipeline pipeline = Pipeline.fromDB(readAccess, writeAccess, pipelineId); String id = pipeline.getId(); // Persist visualization JSONs and build types/tables Collection coll = new ArrayList<>(); coll.add(pipeline); - ops.savePipelinesVisualizationsJSONs(coll, schema); + ops.savePipelinesVisualizationsJSONs(coll, tempSchema); // Generate & save generator data pipeline.saveToDB(); - logger.info("Build completed for schema=" + schema + ", pipeline=" + id); + // Swap rebuilt temp schema into production name, then remove previous schema. + swapSchemas(targetSchema, tempSchema, oldSchema); + + logger.info("Build completed for schema=" + targetSchema + ", pipeline=" + id); + } + + private void swapSchemas(String targetSchema, String tempSchema, String oldSchema) { + try (Connection connection = dataSource.getConnection()) { + DSLContext dsl = DSL.using(connection); + boolean targetRenamed = false; + + try { + dsl.dropSchemaIfExists(DSL.name(oldSchema)).cascade().execute(); + + if (schemaExists(dsl, targetSchema)) { + dsl.alterSchema(DSL.name(targetSchema)).renameTo(DSL.name(oldSchema)).execute(); + targetRenamed = true; + } + + dsl.alterSchema(DSL.name(tempSchema)).renameTo(DSL.name(targetSchema)).execute(); + + if (targetRenamed) { + dsl.dropSchemaIfExists(DSL.name(oldSchema)).cascade().execute(); + } + } catch (Exception swapError) { + // Restore original schema name if swap did not finish. + try { + if (targetRenamed && !schemaExists(dsl, targetSchema) && schemaExists(dsl, oldSchema)) { + dsl.alterSchema(DSL.name(oldSchema)).renameTo(DSL.name(targetSchema)).execute(); + } + } catch (Exception rollbackError) { + logger.error("Failed to rollback schema swap for {}: {}", targetSchema, rollbackError.getMessage(), rollbackError); + } + throw new IllegalStateException("Failed to swap rebuilt schema for pipeline " + targetSchema, swapError); + } finally { + // Ensure transient schemas do not remain after success or failure. + safeDropSchemaIfExists(dsl, tempSchema); + safeDropSchemaIfExists(dsl, oldSchema); + } + } catch (Exception e) { + throw new RuntimeException("Schema swap failed for pipeline " + targetSchema, e); + } + } + + private void cleanupTransientSchemas(String tempSchema, String oldSchema) { + try (Connection connection = dataSource.getConnection()) { + DSLContext dsl = DSL.using(connection); + safeDropSchemaIfExists(dsl, tempSchema); + safeDropSchemaIfExists(dsl, oldSchema); + } catch (Exception e) { + throw new RuntimeException("Failed to cleanup transient schemas for pipeline build", e); + } + } + + private void safeDropSchemaIfExists(DSLContext dsl, String schemaName) { + if (schemaName == null || schemaName.isBlank()) { + return; + } + if (schemaExists(dsl, schemaName)) { + dsl.dropSchemaIfExists(DSL.name(schemaName)).cascade().execute(); + } + } + + private boolean schemaExists(DSLContext dsl, String schemaName) { + return dsl.fetchExists( + dsl.selectOne() + .from(DSL.table(DSL.name("information_schema", "schemata"))) + .where(DSL.field(DSL.name("information_schema", "schemata", "schema_name"), String.class).eq(schemaName)) + ); + } + + private String normalizeSchemaName(String schema) { + if (schema == null || schema.isBlank()) { + throw new IllegalArgumentException("Pipeline schema must not be null/blank"); + } + return schema.trim(); + } + + private void assertSafePipelineSchema(String schemaName) { + if (schemaName.equalsIgnoreCase(appDbSchema)) { + throw new IllegalArgumentException("Refusing to rebuild reserved app schema: " + schemaName); + } + if (schemaName.equalsIgnoreCase(DBConstants.DB_SCHEMA_UIMA)) { + throw new IllegalArgumentException("Refusing to rebuild reserved UIMA schema: " + schemaName); + } + } + + private void assertSafeAuxSchema(String schemaName) { + if (schemaName.equalsIgnoreCase(appDbSchema)) { + throw new IllegalArgumentException("Refusing to use reserved app schema as transient schema: " + schemaName); + } + if (schemaName.equalsIgnoreCase(DBConstants.DB_SCHEMA_UIMA)) { + throw new IllegalArgumentException("Refusing to use reserved UIMA schema as transient schema: " + schemaName); + } } } diff --git a/src/main/java/org/texttechnologylab/udav/api/service/utils/GeneratorConverter.java b/src/main/java/org/texttechnologylab/udav/api/service/utils/GeneratorConverter.java index 9327f2bd..99702198 100644 --- a/src/main/java/org/texttechnologylab/udav/api/service/utils/GeneratorConverter.java +++ b/src/main/java/org/texttechnologylab/udav/api/service/utils/GeneratorConverter.java @@ -8,7 +8,11 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; public final class GeneratorConverter { @@ -19,17 +23,79 @@ private GeneratorConverter() { } /** - * old -> new (you already have this) + * old -> new + * + * Ensures a top-level generators array exists and all generator definitions are + * flattened out of sources[*].createsGenerators. */ public static String toNewFormat(String oldJson) { try { JsonNode root = MAPPER.readTree(oldJson); - List generators = OldToNew.extractGenerators(root); - ObjectNode out = MAPPER.createObjectNode(); - ArrayNode arr = MAPPER.createArrayNode(); - generators.forEach(arr::add); - out.set("generators", arr); - return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(out); + if (!(root instanceof ObjectNode rootObj)) { + throw new IllegalArgumentException("Pipeline JSON root must be an object"); + } + + ObjectNode normalized = rootObj.deepCopy(); + + List ordered = new ArrayList<>(); + Map byId = new LinkedHashMap<>(); + Set noIdFingerprints = new LinkedHashSet<>(); + + // Keep existing top-level generators first and enrich them with nested data if needed. + JsonNode existingTopLevel = normalized.get("generators"); + if (existingTopLevel != null && existingTopLevel.isArray()) { + for (JsonNode g : existingTopLevel) { + if (g instanceof ObjectNode objectNode) { + addOrMergeGenerator( + normalizeGenerator(objectNode, null), + ordered, + byId, + noIdFingerprints + ); + } + } + } + + JsonNode sources = normalized.get("sources"); + if (sources != null && sources.isArray()) { + for (JsonNode sourceNode : sources) { + if (!(sourceNode instanceof ObjectNode sourceObj)) { + continue; + } + + String sourceId = textOrNull(sourceObj.get("id")); + OldToNew.extractFromArray(sourceObj.get("createsGenerators"), sourceId, ordered, byId, noIdFingerprints); + OldToNew.extractFromArray(sourceObj.get("derivedGenerators"), sourceId, ordered, byId, noIdFingerprints); + + // Normalized GET output must not keep in-source generator definitions. + sourceObj.remove("createsGenerators"); + sourceObj.remove("derivedGenerators"); + } + } + + JsonNode legacyDerived = normalized.get("derivedGenerators"); + if (legacyDerived != null && legacyDerived.isArray()) { + for (JsonNode g : legacyDerived) { + if (g instanceof ObjectNode objectNode) { + addOrMergeGenerator( + normalizeGenerator(objectNode, null), + ordered, + byId, + noIdFingerprints + ); + } + } + } + + ArrayNode generatorsOut = MAPPER.createArrayNode(); + for (ObjectNode generator : ordered) { + generatorsOut.add(generator); + } + + normalized.set("generators", generatorsOut); + normalized.remove("derivedGenerators"); + + return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(normalized); } catch (Exception e) { throw new RuntimeException(e); } @@ -163,83 +229,114 @@ public static void main(String[] args) { System.out.println(toNewFormat(toOldFormat(newFormat))); } - /** - * your previous old->new helper collapsed here for completeness - */ - private static final class OldToNew { - static List extractGenerators(JsonNode root) { - List result = new ArrayList<>(); - - JsonNode sources = root.get("sources"); - if (sources != null && sources.isArray()) { - for (JsonNode sourceNode : sources) { - String sourceId = sourceNode.path("id").asText(null); - String sourceType = sourceNode.path("type").asText(null); - collectFromNode(sourceNode, sourceId, sourceType, result); - } + private static void addOrMergeGenerator(ObjectNode candidate, + List ordered, + Map byId, + Set noIdFingerprints) { + String id = textOrNull(candidate.get("id")); + if (id != null) { + ObjectNode existing = byId.get(id); + if (existing == null) { + byId.put(id, candidate); + ordered.add(candidate); + return; } + mergeMissing(existing, candidate); + return; + } - JsonNode derived = root.get("derivedGenerators"); - if (derived != null && derived.isArray()) { - for (JsonNode d : derived) { - if (d.isObject()) { - result.add(((ObjectNode) d).deepCopy()); - } - } + String fingerprint = candidate.toString(); + if (noIdFingerprints.add(fingerprint)) { + ordered.add(candidate); + } + } + + private static void mergeMissing(ObjectNode target, ObjectNode source) { + for (String fieldName : List.of("name", "type", "source", "settings")) { + JsonNode targetValue = target.get(fieldName); + JsonNode sourceValue = source.get(fieldName); + if (isMissing(targetValue) && !isMissing(sourceValue)) { + target.set(fieldName, sourceValue.deepCopy()); } + } + } - return result; + private static boolean isMissing(JsonNode node) { + if (node == null || node.isNull()) { + return true; } + if (node.isTextual()) { + return node.asText().isBlank(); + } + return false; + } - private static void collectFromNode(JsonNode node, - String parentSourceId, - String parentSourceType, - List target) { + private static ObjectNode normalizeGenerator(ObjectNode src, String sourceId) { + ObjectNode out = src.deepCopy(); + out.remove("createsGenerators"); + out.remove("derivedGenerators"); - if (node.has("type") && !node.has("sources")) { - target.add(buildGenerator(node, parentSourceId, parentSourceType)); - } + String type = textOrNull(out.get("type")); + if (type != null && isMissing(out.get("name"))) { + out.put("name", "New " + type); + } - JsonNode creates = node.get("createsGenerators"); - if (creates != null && creates.isArray()) { - for (JsonNode child : creates) { - collectFromNode(child, parentSourceId, parentSourceType, target); - } - } + if (type != null && out.get("settings") == null && !out.has("fromGenerators")) { + out.set("settings", MAPPER.createObjectNode()); + } - JsonNode derived = node.get("derivedGenerators"); - if (derived != null && derived.isArray()) { - for (JsonNode child : derived) { - collectFromNode(child, parentSourceId, parentSourceType, target); - } - } + // For extracted source-nested generators, always reference source by source id. + if (sourceId != null && !sourceId.isBlank() && !out.has("fromGenerators")) { + out.put("source", sourceId); } - private static ObjectNode buildGenerator(JsonNode src, - String parentSourceId, - String parentSourceType) { - ObjectNode out = MAPPER.createObjectNode(); + return out; + } - String type = src.path("type").asText("Unknown"); - String id = src.path("id").asText(null); - JsonNode settings = src.path("settings"); + private static String textOrNull(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + String value = node.asText(null); + if (value == null || value.isBlank()) { + return null; + } + return value; + } - out.put("name", "New " + type); - out.put("type", type); - - // mapping, adjust as needed - String mappedSource = (parentSourceType != null) - ? parentSourceType - : (parentSourceId != null ? parentSourceId : "uima.tcas.Annotation"); - out.put("source", mappedSource); - - if (settings != null && !settings.isMissingNode() && !settings.isNull()) { - out.set("settings", settings.deepCopy()); - } else { - out.set("settings", MAPPER.createObjectNode()); + private static final class OldToNew { + static void extractFromArray(JsonNode nodes, + String sourceId, + List ordered, + Map byId, + Set noIdFingerprints) { + if (nodes == null || !nodes.isArray()) { + return; + } + for (JsonNode node : nodes) { + if (node instanceof ObjectNode objectNode) { + extractFromNode(objectNode, sourceId, ordered, byId, noIdFingerprints); + } + } + } + + private static void extractFromNode(ObjectNode node, + String sourceId, + List ordered, + Map byId, + Set noIdFingerprints) { + boolean isGeneratorDefinition = node.has("type") || node.has("fromGenerators"); + if (isGeneratorDefinition) { + addOrMergeGenerator( + normalizeGenerator(node, sourceId), + ordered, + byId, + noIdFingerprints + ); } - return out; + extractFromArray(node.get("createsGenerators"), sourceId, ordered, byId, noIdFingerprints); + extractFromArray(node.get("derivedGenerators"), sourceId, ordered, byId, noIdFingerprints); } } } diff --git a/src/main/java/org/texttechnologylab/udav/controller/AppController.java b/src/main/java/org/texttechnologylab/udav/controller/AppController.java index 3f3b5a37..a8d0299c 100644 --- a/src/main/java/org/texttechnologylab/udav/controller/AppController.java +++ b/src/main/java/org/texttechnologylab/udav/controller/AppController.java @@ -2,65 +2,91 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.multipart.MultipartFile; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.texttechnologylab.udav.api.service.PipelineService; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; @Controller public class AppController { private final ObjectMapper mapper = new ObjectMapper(); private final PipelineService service; + private final List lockedPipelines = List.of("0c1953d4-843b-4de4-a44e-1c607ed5a584"); + + @Value("${app.llm.base-url}") + private String llmUrl; + + @Value("${app.llm.api-token}") + private String llmToken; public AppController(PipelineService service) { this.service = service; } public String getPipelines() throws Exception { - return mapper.writeValueAsString(service.listIds(0, 100, "")); - } - - public String getWidgetsById(String id) throws Exception { - return service.get(id).get("widgets").toString(); + return mapper.writeValueAsString(service.listSummaries(0, 100, "")); } public String getConfigById(String id) throws Exception { return service.get(id).toString(); } + private String ensureValidConfig(String json) throws Exception { + JsonNode jsonNode = mapper.readTree(json); + ObjectNode objectNode; + + if (jsonNode.isObject()) { + objectNode = (ObjectNode) jsonNode; + } else { + objectNode = mapper.createObjectNode(); + } + + objectNode.put("id", UUID.randomUUID().toString()); + + return mapper.writeValueAsString(objectNode); + } + @GetMapping("/") public String index(Model model) throws Exception { model.addAttribute("pipelines", getPipelines()); + model.addAttribute("lockedPipelines", lockedPipelines); return "/pages/index/index"; } @GetMapping("/view/{id}") public String view(@PathVariable("id") String id, Model model) throws Exception { - model.addAttribute("id", id); model.addAttribute("pipelines", getPipelines()); - model.addAttribute("widgets", getWidgetsById(id)); + model.addAttribute("lockedPipelines", lockedPipelines); + model.addAttribute("config", getConfigById(id)); + model.addAttribute("chatbot", !llmUrl.isEmpty() && !llmToken.isEmpty()); return "/pages/view/view"; } @GetMapping("/editor") public String editorNew(Model model) throws Exception { - model.addAttribute("config", "{}"); + model.addAttribute("config", "{\"id\":\"" + UUID.randomUUID().toString() + "\"}"); return "/pages/editor/editor"; } @PostMapping("/editor") public String editorFile(@RequestParam("file") MultipartFile file, Model model) throws Exception { - model.addAttribute("config", new String(file.getBytes(), StandardCharsets.UTF_8)); + String json = new String(file.getBytes(), StandardCharsets.UTF_8); + model.addAttribute("config", ensureValidConfig(json)); return "/pages/editor/editor"; } @@ -69,6 +95,6 @@ public String editorFile(@RequestParam("file") MultipartFile file, Model model) public String editorEdit(@PathVariable("id") String id, Model model) throws Exception { model.addAttribute("config", getConfigById(id)); - return "/pages/editor/editor"; + return lockedPipelines.contains(id) ? "/error/404" : "/pages/editor/editor"; } } diff --git a/src/main/java/org/texttechnologylab/udav/database/DBConstants.java b/src/main/java/org/texttechnologylab/udav/database/DBConstants.java index 9cf2114e..c4cf58f4 100644 --- a/src/main/java/org/texttechnologylab/udav/database/DBConstants.java +++ b/src/main/java/org/texttechnologylab/udav/database/DBConstants.java @@ -20,11 +20,13 @@ private DBConstants() {} // Generator: MapCoordinates public static final String TABLENAME_GENERATORDATA_MAPCOORDINATES = "GENERATORDATA_MAPCOORDINATES"; + public static final String TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES = "GENERATORDATA_MAPCOORDINATES_EDGES"; // General table attributes (also used in Generatordata tables) public static final String TABLEATTR_PIPELINEID = "PIPELINEID"; public static final String TABLEATTR_JSONSTR = "JSONSTR"; public static final String TABLEATTR_GENERATORID = "GENERATORID"; + public static final String TABLEATTR_GENERATORTYPE = "GENERATORTYPE"; public static final String TABLEATTR_FILENAME = "FILENAME"; public static final String TABLEATTR_SOFA = "SOFA"; @@ -44,7 +46,11 @@ private DBConstants() {} public static final String TABLEATTR_GENERATORDATA_BEGIN = "BEGIN"; public static final String TABLEATTR_GENERATORDATA_END = "_END"; + // Generatordata table attributes: MapCoordinates edges + public static final String TABLEATTR_GENERATORDATA_EDGE_FROM = "EDGE_FROM"; + public static final String TABLEATTR_GENERATORDATA_EDGE_TO = "EDGE_TO"; + public static final String TABLEATTR_GENERATORDATA_EDGE_NUMBER = "EDGE_NUMBER"; // Other, technical attributes public static final int DEFAULTSIZE_VARCHAR = 255; -} +} \ No newline at end of file diff --git a/src/main/java/org/texttechnologylab/udav/db/SchemaInitializer.java b/src/main/java/org/texttechnologylab/udav/db/SchemaInitializer.java new file mode 100644 index 00000000..829f5e14 --- /dev/null +++ b/src/main/java/org/texttechnologylab/udav/db/SchemaInitializer.java @@ -0,0 +1,66 @@ +package org.texttechnologylab.udav.db; + +import org.jooq.DSLContext; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import static org.jooq.impl.DSL.constraint; +import static org.jooq.impl.DSL.name; + +/** + * Ensures the minimal set of tables needed by the UI exist, independent of optional importers. + * + * This prevents runtime failures like "relation json_data does not exist" when repositories query + * optional tables before/without running the corresponding importer. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class SchemaInitializer implements ApplicationRunner { + + private static final Logger LOG = LoggerFactory.getLogger(SchemaInitializer.class); + + private final DSLContext dsl; + + @Value("${app.db.schema:public}") + private String schema; + + public SchemaInitializer(DSLContext dsl) { + this.dsl = dsl; + } + + @Override + public void run(ApplicationArguments args) { + // Ensure schema exists + dsl.createSchemaIfNotExists(DSL.name(schema)).execute(); + + // Ensure json_data exists (used by AnnotationController -> UIMATypeRepository) + dsl.createTableIfNotExists(name(schema, SchemaObjectNames.TABLE_JSON_DATA)) + .column(SchemaObjectNames.COL_JSON_DATA_SOURCEFILE_NAME, SQLDataType.VARCHAR(255).nullable(false)) + // For Postgres, CLOB renders to TEXT + .column(SchemaObjectNames.COL_JSON_DATA_JSON, SQLDataType.CLOB.nullable(false)) + .constraints(constraint("PK_" + SchemaObjectNames.TABLE_JSON_DATA) + .primaryKey(SchemaObjectNames.COL_JSON_DATA_SOURCEFILE_NAME)) + .execute(); + + // Ensure pipeline exists (used by the UI and by MissingSchemaScanner) + dsl.createTableIfNotExists(name(schema, SchemaObjectNames.TABLE_PIPELINE)) + .column(SchemaObjectNames.COL_PIPELINE_ID, SQLDataType.VARCHAR(255).nullable(false)) + .column(SchemaObjectNames.COL_PIPELINE_NAME, SQLDataType.VARCHAR(255).nullable(false)) + .column(SchemaObjectNames.COL_PIPELINE_JSON, SQLDataType.CLOB.nullable(false)) + .constraints(constraint("PK_" + SchemaObjectNames.TABLE_PIPELINE) + .primaryKey(SchemaObjectNames.COL_PIPELINE_ID)) + .execute(); + + LOG.info("Ensured DB objects exist: schema={}, tables=[{}, {}]", schema, + SchemaObjectNames.TABLE_JSON_DATA, + SchemaObjectNames.TABLE_PIPELINE); + } +} diff --git a/src/main/java/org/texttechnologylab/udav/db/SchemaObjectNames.java b/src/main/java/org/texttechnologylab/udav/db/SchemaObjectNames.java new file mode 100644 index 00000000..49a32cfb --- /dev/null +++ b/src/main/java/org/texttechnologylab/udav/db/SchemaObjectNames.java @@ -0,0 +1,19 @@ +package org.texttechnologylab.udav.db; + +/** Central place for naming DB objects that are used across repositories/importers. */ +public final class SchemaObjectNames { + private SchemaObjectNames() {} + + public static final String TABLE_JSON_DATA = "json_data"; + public static final String COL_JSON_DATA_SOURCEFILE_NAME = "sourcefile_name"; + public static final String COL_JSON_DATA_JSON = "json"; + + public static final String TABLE_PIPELINE = "pipeline"; + public static final String COL_PIPELINE_ID = "pipeline_id"; + public static final String COL_PIPELINE_NAME = "pipeline_name"; + public static final String COL_PIPELINE_JSON = "json"; + + public static final String TABLE_PIPELINE_LOCKS = "pipeline_locks"; + public static final String COL_PIPELINE_LOCKS_PIPELINE_ID = "pipeline_id"; + public static final String COL_PIPELINE_LOCKS_LOCKED_AT = "locked_at"; +} diff --git a/src/main/java/org/texttechnologylab/udav/generators/GeneratorUIMA.java b/src/main/java/org/texttechnologylab/udav/generators/GeneratorUIMA.java index ecf9d38b..dc11c9b8 100644 --- a/src/main/java/org/texttechnologylab/udav/generators/GeneratorUIMA.java +++ b/src/main/java/org/texttechnologylab/udav/generators/GeneratorUIMA.java @@ -43,18 +43,23 @@ protected org.jooq.Field resolveFeatureField(org.jooq.DSLContext dsl, for (String c : extraCandidates) if (c != null && !c.isBlank()) candidates.add(c.trim()); } + // Importer writes feature columns as "_f__<8-hex-of-feature-FQN>" + // (see JooqDatabaseWriter.featColName). Fetch the table's columns once and match in Java so we + // don't have to deal with LIKE-escaping the underscores in the prefix. + org.jooq.Field COL = DSL.field(DSL.name("column_name"), String.class); + java.util.List columns = dsl.select(COL) + .from(DSL.table(DSL.name("information_schema", "columns"))) + .where(DSL.field(DSL.name("table_schema"), String.class).eq(schema)) + .and(DSL.field(DSL.name("table_name"), String.class).eq(tableHash)) + .fetch(COL); + java.util.regex.Pattern hex8 = java.util.regex.Pattern.compile("[0-9a-f]{8}"); for (String shortName : candidates) { - String physical = SourceUIMA.sanitize(tableHash + "_f_" + shortName); - boolean exists = dsl.fetchExists( - DSL.selectOne() - .from(DSL.table(DSL.name("information_schema", "columns"))) - .where(DSL.field(DSL.name("table_schema"), String.class).eq(schema)) - .and(DSL.field(DSL.name("table_name"), String.class).eq(tableHash)) - .and(DSL.field(DSL.name("column_name"), String.class).eq(physical)) - ); - if (exists) { - tempFeatureName = shortName; - return DSL.field(DSL.name(schema, tableHash, physical), String.class); + String prefix = SourceUIMA.sanitize(tableHash + "_f_" + shortName) + "_"; + for (String col : columns) { + if (col.startsWith(prefix) && hex8.matcher(col.substring(prefix.length())).matches()) { + tempFeatureName = shortName; + return DSL.field(DSL.name(schema, tableHash, col), String.class); + } } } diff --git a/src/main/java/org/texttechnologylab/udav/generators/MapCoordinates.java b/src/main/java/org/texttechnologylab/udav/generators/MapCoordinates.java index 1a0e820f..e6a22a87 100644 --- a/src/main/java/org/texttechnologylab/udav/generators/MapCoordinates.java +++ b/src/main/java/org/texttechnologylab/udav/generators/MapCoordinates.java @@ -18,33 +18,47 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; public class MapCoordinates extends Generator { private List entries; + private List edges; // optional — may be empty but never null after setup() - public MapCoordinates(String id, JSONView configGenerator, JSONView configBundle, GeneratorSettings settingsBundle, DBAccess dbAccess) { + public MapCoordinates(String id, JSONView configGenerator, JSONView configBundle, + GeneratorSettings settingsBundle, DBAccess dbAccess) { super(id, configGenerator, configBundle, settingsBundle, dbAccess); } + @Override public void setup() throws SQLException { entries = new ArrayList<>(); + edges = new ArrayList<>(); + double coordinateScale = readCoordinateScaleSetting(); + if (SourceJson.class.equals(source.getClass())) { SourceJson sourceJson = (SourceJson) source; + String inputFormat = readStringSetting("inputFormat"); + if ("edgePairs".equalsIgnoreCase(inputFormat)) { + setupFromEdgePairs(sourceJson, coordinateScale); + return; + } + List> keysMap = sourceJson.generateKeysMap(settings); for (Map map : keysMap) { + // ── Vertex entries ──────────────────────────────────────────── + // Coordinates (mandatory field) Map coordinates = (Map) map.get("coordinates"); if (coordinates == null) continue; ArrayList coordinatesNumbers = new ArrayList<>(); for (int c = 0; true; c++) { - String coordinateString = Integer.toString(c); - Number coordinateNumber = coordinates.get(coordinateString); + Number coordinateNumber = coordinates.get(Integer.toString(c)); if (coordinateNumber == null) break; - coordinatesNumbers.add(coordinateNumber); + coordinatesNumbers.add(coordinateNumber.doubleValue() * coordinateScale); } if (coordinatesNumbers.isEmpty()) continue; @@ -63,30 +77,204 @@ public void setup() throws SQLException { // OutsideColor Color outsideColor = Color.WHITE; // TODO: Custom color - entries.add(new Entry(sourceJson.getSingleFileName(), label, coordinatesNumbers, scale, fillColor, strokeColor, outsideColor)); - // TODO: Allow multiple file sources for JSON + entries.add(new Entry(sourceJson.getSingleFileName(), label, + coordinatesNumbers, scale, fillColor, strokeColor, outsideColor)); + + // ── Edges (optional, nested under each vertex entry) ────────── + // Expected JSON shape (example): + // "edges": [ + // { "to": 2, "number": 1.5, "color": "#ff0000", "label": "route A" }, + // { "to": 5, "number": 0.8, "color": { "Red":1.0,"Green":0.5,"Blue":0.0,"Alpha":1.0 } } + // ] + // "from" is implicitly the index of the current entry (entries.size() - 1 after the add above). + List> edgeList = + (List>) map.get("edges"); + if (edgeList != null) { + int fromIndex = entries.size() - 1; // just added above + for (Map edgeMap : edgeList) { + Number toIndex = (Number) edgeMap.get("to"); + if (toIndex == null) continue; // "to" is mandatory for an edge + + Number edgeNumber = (Number) edgeMap.get("number"); + String edgeLabel = (String) edgeMap.get("label"); + Color edgeColor = mapRGBAorStringToColor(edgeMap.get("color"), Color.GRAY); + + edges.add(new Edge(sourceJson.getSingleFileName(), + fromIndex, toIndex.intValue(), + edgeNumber, edgeLabel, edgeColor)); + } + } } } } - private Color mapRGBAorStringToColor(Object colorObj, Color defaultColor) { + private void setupFromEdgePairs(SourceJson sourceJson, double coordinateScale) { + double epsilon = readDoubleSetting("epsilon", 1e-6d); + String filename = sourceJson.getSingleFileName(); + + // Preferred path: same settings grammar as other generators (keysMap/keys/fixedKeys). + List> mappedEdges = sourceJson.generateKeysMapRowWise(settings); + if (!mappedEdges.isEmpty()) { + setupFromMappedEdgePairs(filename, mappedEdges, epsilon, coordinateScale); + return; + } + + // Backward-compatible fallback for raw edge arrays: [[{x,y},{x,y}], ...] + Object node = sourceJson.getSingleFileJSONView().getNode(); + if (!(node instanceof List edgePairs)) { + return; + } + + for (Object edgeObj : edgePairs) { + if (!(edgeObj instanceof List edgePair) || edgePair.size() < 2) { + continue; + } + + PointData fromPoint = scalePoint(readPoint(edgePair.get(0)), coordinateScale); + PointData toPoint = scalePoint(readPoint(edgePair.get(1)), coordinateScale); + if (fromPoint == null || toPoint == null) { + continue; + } + + int fromIndex = findOrAddVertex(filename, fromPoint, epsilon); + int toIndex = findOrAddVertex(filename, toPoint, epsilon); + if (fromIndex == toIndex) { + continue; + } + + edges.add(new Edge(filename, fromIndex, toIndex, null, null, Color.GRAY)); + } + } + + private void setupFromMappedEdgePairs(String filename, List> mappedEdges, double epsilon, double coordinateScale) { + for (Map edgeMap : mappedEdges) { + PointData fromPoint = scalePoint(readPointFromMapped(edgeMap.get("from")), coordinateScale); + PointData toPoint = scalePoint(readPointFromMapped(edgeMap.get("to")), coordinateScale); + if (fromPoint == null || toPoint == null) { + continue; + } + + int fromIndex = findOrAddVertex(filename, fromPoint, epsilon); + int toIndex = findOrAddVertex(filename, toPoint, epsilon); + if (fromIndex == toIndex) { + continue; + } + + Number edgeNumber = edgeMap.get("number") instanceof Number n ? n : null; + String edgeLabel = edgeMap.get("label") instanceof String s ? s : null; + Color edgeColor = mapRGBAorStringToColor(edgeMap.get("color"), Color.GRAY); + edges.add(new Edge(filename, fromIndex, toIndex, edgeNumber, edgeLabel, edgeColor)); + } + } + + private PointData readPointFromMapped(Object mappedPoint) { + if (!(mappedPoint instanceof Map pointMap)) { + return null; + } + Number xNumber = getNumber(pointMap, "0", "x", "X"); + Number yNumber = getNumber(pointMap, "1", "y", "Y"); + if (xNumber == null || yNumber == null) { + return null; + } + return new PointData(xNumber.doubleValue(), yNumber.doubleValue()); + } + + private PointData scalePoint(PointData pointData, double coordinateScale) { + if (pointData == null) return null; + if (coordinateScale == 1.0d) return pointData; + return new PointData(pointData.x * coordinateScale, pointData.y * coordinateScale); + } + + private double readCoordinateScaleSetting() { try { - if (colorObj == null) { - return defaultColor; - } else if (String.class.equals(colorObj.getClass())) { - return Color.decode((String) colorObj); - } else if (colorObj instanceof Map) { - Map colorObjMap = (Map) colorObj; - Number red = colorObjMap.getOrDefault("Red", 0.0); - Number green = colorObjMap.getOrDefault("Green", 0.0); - Number blue = colorObjMap.getOrDefault("Blue", 0.0); - Number alpha = colorObjMap.getOrDefault("Alpha", 0.0); - return new Color(red.floatValue(), green.floatValue(), blue.floatValue(), alpha.floatValue()); + Object value = configGenerator.get("settings").get("scale").getNode(); + if (value instanceof Number number) { + double parsed = number.doubleValue(); + return Double.isFinite(parsed) ? parsed : 1.0d; } - } catch (Exception ignored) {} - return defaultColor; + if (value instanceof String s) { + double parsed = Double.parseDouble(s.trim()); + return Double.isFinite(parsed) ? parsed : 1.0d; + } + } catch (Exception ignored) { + // Fall through to default. + } + return 1.0d; + } + + private Number getNumber(Map map, String... keys) { + for (String key : keys) { + Object value = map.get(key); + if (value instanceof Number number) { + return number; + } + } + return null; + } + + private int findOrAddVertex(String filename, PointData point, double epsilon) { + for (int i = 0; i < entries.size(); i++) { + Entry entry = entries.get(i); + if (entry.coordinates == null || entry.coordinates.size() < 2) { + continue; + } + double x = entry.coordinates.get(0).doubleValue(); + double y = entry.coordinates.get(1).doubleValue(); + if (Math.abs(x - point.x) <= epsilon && Math.abs(y - point.y) <= epsilon) { + return i; + } + } + + List coordinates = List.of(point.x, point.y); + entries.add(new Entry(filename, null, coordinates, null, Color.BLUE, Color.RED, Color.WHITE)); + return entries.size() - 1; + } + + private PointData readPoint(Object rawPoint) { + if (!(rawPoint instanceof Map rawMap)) { + return null; + } + Object xObj = rawMap.get("x"); + if (xObj == null) { + xObj = rawMap.get("X"); + } + Object yObj = rawMap.get("y"); + if (yObj == null) { + yObj = rawMap.get("Y"); + } + if (!(xObj instanceof Number xNumber) || !(yObj instanceof Number yNumber)) { + return null; + } + return new PointData(xNumber.doubleValue(), yNumber.doubleValue()); } + private String readStringSetting(String key) { + try { + Object value = configGenerator.get("settings").get(key).getNode(); + return value == null ? null : Objects.toString(value, null); + } catch (Exception ignored) { + return null; + } + } + + private double readDoubleSetting(String key, double defaultValue) { + try { + Object value = configGenerator.get("settings").get(key).getNode(); + if (value instanceof Number number) { + double parsed = number.doubleValue(); + return parsed > 0 ? parsed : defaultValue; + } + if (value instanceof String s) { + double parsed = Double.parseDouble(s.trim()); + return parsed > 0 ? parsed : defaultValue; + } + } catch (Exception ignored) { + // Fall through to default. + } + return defaultValue; + } + + @Override public void writeToDB() throws SQLException { @@ -94,83 +282,225 @@ public void writeToDB() throws SQLException { try (Connection connection = dbAccess.getDataSource().getConnection()) { DSLContext dsl = DSL.using(connection); - dsl.createTableIfNotExists(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES)) - .column(DBConstants.TABLEATTR_GENERATORID, org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) - .column(DBConstants.TABLEATTR_FILENAME, org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) - .column(DBConstants.TABLEATTR_GENERATORDATA_LABEL, org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(true)) - .column(DBConstants.TABLEATTR_GENERATORDATA_COORDINATES, org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) - .column(DBConstants.TABLEATTR_GENERATORDATA_SCALE, org.jooq.impl.SQLDataType.DOUBLE.nullable(true)) - .column(DBConstants.TABLEATTR_GENERATORDATA_COLOR_FILL, org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) - .column(DBConstants.TABLEATTR_GENERATORDATA_COLOR_STROKE, org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) - .column(DBConstants.TABLEATTR_GENERATORDATA_COLOR_OUTSIDE, org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(true)) + // ── Vertex table (unchanged) ────────────────────────────────────── + dsl.createTableIfNotExists( + DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES)) + .column(DBConstants.TABLEATTR_GENERATORID, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .column(DBConstants.TABLEATTR_FILENAME, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .column(DBConstants.TABLEATTR_GENERATORDATA_LABEL, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(true)) + .column(DBConstants.TABLEATTR_GENERATORDATA_COORDINATES, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .column(DBConstants.TABLEATTR_GENERATORDATA_SCALE, + org.jooq.impl.SQLDataType.DOUBLE.nullable(true)) + .column(DBConstants.TABLEATTR_GENERATORDATA_COLOR_FILL, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .column(DBConstants.TABLEATTR_GENERATORDATA_COLOR_STROKE, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .column(DBConstants.TABLEATTR_GENERATORDATA_COLOR_OUTSIDE, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(true)) .execute(); - - // ---------- Table ---------- - Table TABLE = DSL.table(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES)); - - - // ---------- Columns (schema-qualified & quoted) ---------- - Field GENERATORID = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, DBConstants.TABLEATTR_GENERATORID), String.class); - Field FILENAME = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, DBConstants.TABLEATTR_FILENAME), String.class); - Field LABEL = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, DBConstants.TABLEATTR_GENERATORDATA_LABEL), String.class); - Field COORDINATES = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, DBConstants.TABLEATTR_GENERATORDATA_COORDINATES), String.class); - Field SCALE = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, DBConstants.TABLEATTR_GENERATORDATA_SCALE), Double.class); - Field COLOR_FILL = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, DBConstants.TABLEATTR_GENERATORDATA_COLOR_FILL), String.class); - Field COLOR_STROKE = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, DBConstants.TABLEATTR_GENERATORDATA_COLOR_STROKE), String.class); - Field COLOR_OUTSIDE = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, DBConstants.TABLEATTR_GENERATORDATA_COLOR_OUTSIDE), String.class); - - List batch = new ArrayList<>(); + Table VERTEX_TABLE = + DSL.table(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES)); + + Field GENERATORID = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, + DBConstants.TABLEATTR_GENERATORID), String.class); + Field FILENAME = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, + DBConstants.TABLEATTR_FILENAME), String.class); + Field LABEL = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, + DBConstants.TABLEATTR_GENERATORDATA_LABEL), String.class); + Field COORDINATES = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, + DBConstants.TABLEATTR_GENERATORDATA_COORDINATES), String.class); + Field SCALE = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, + DBConstants.TABLEATTR_GENERATORDATA_SCALE), Double.class); + Field COLOR_FILL = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, + DBConstants.TABLEATTR_GENERATORDATA_COLOR_FILL), String.class); + Field COLOR_STROKE = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, + DBConstants.TABLEATTR_GENERATORDATA_COLOR_STROKE), String.class); + Field COLOR_OUTSIDE = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES, + DBConstants.TABLEATTR_GENERATORDATA_COLOR_OUTSIDE), String.class); + + List vertexBatch = new ArrayList<>(); for (Entry e : entries) { - String fillColorStr = String.format("#%02x%02x%02x", e.fillColor.getRed(), e.fillColor.getGreen(), e.fillColor.getBlue()); - String strokeColorStr = String.format("#%02x%02x%02x", e.strokeColor.getRed(), e.strokeColor.getGreen(), e.strokeColor.getBlue()); - String outsideColorStr = String.format("#%02x%02x%02x", e.outsideColor.getRed(), e.outsideColor.getGreen(), e.outsideColor.getBlue()); - batch.add( - dsl.insertInto(TABLE) - .columns(GENERATORID, FILENAME, LABEL, COORDINATES, SCALE, COLOR_FILL, COLOR_STROKE, COLOR_OUTSIDE) - .values(id, e.filename, e.label, coordinatesListToString(e.coordinates), e.scale.doubleValue(), fillColorStr, strokeColorStr, outsideColorStr) + String fillColorStr = colorToHex(e.fillColor); + String strokeColorStr = colorToHex(e.strokeColor); + String outsideColorStr = colorToHex(e.outsideColor); + vertexBatch.add( + dsl.insertInto(VERTEX_TABLE) + .columns(GENERATORID, FILENAME, LABEL, COORDINATES, + SCALE, COLOR_FILL, COLOR_STROKE, COLOR_OUTSIDE) + .values(id, e.filename, e.label, + coordinatesListToString(e.coordinates), + e.scale != null ? e.scale.doubleValue() : null, + fillColorStr, strokeColorStr, outsideColorStr) ); } - if (!batch.isEmpty()) dsl.batch(batch).execute(); + if (!vertexBatch.isEmpty()) dsl.batch(vertexBatch).execute(); + + // ── Edge table (always created; rows may be empty for point-only generators) ── + dsl.createTableIfNotExists( + DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES)) + .column(DBConstants.TABLEATTR_GENERATORID, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .column(DBConstants.TABLEATTR_FILENAME, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .column(DBConstants.TABLEATTR_GENERATORDATA_EDGE_FROM, + org.jooq.impl.SQLDataType.INTEGER.nullable(false)) + .column(DBConstants.TABLEATTR_GENERATORDATA_EDGE_TO, + org.jooq.impl.SQLDataType.INTEGER.nullable(false)) + .column(DBConstants.TABLEATTR_GENERATORDATA_EDGE_NUMBER, + org.jooq.impl.SQLDataType.DOUBLE.nullable(true)) + .column(DBConstants.TABLEATTR_GENERATORDATA_LABEL, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(true)) + .column(DBConstants.TABLEATTR_GENERATORDATA_COLOR_FILL, + org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .execute(); + + if (!edges.isEmpty()) { + Table EDGE_TABLE = + DSL.table(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES)); + + Field E_GENERATORID = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, + DBConstants.TABLEATTR_GENERATORID), String.class); + Field E_FILENAME = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, + DBConstants.TABLEATTR_FILENAME), String.class); + Field E_FROM = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, + DBConstants.TABLEATTR_GENERATORDATA_EDGE_FROM), Integer.class); + Field E_TO = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, + DBConstants.TABLEATTR_GENERATORDATA_EDGE_TO), Integer.class); + Field E_NUMBER = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, + DBConstants.TABLEATTR_GENERATORDATA_EDGE_NUMBER), Double.class); + Field E_LABEL = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, + DBConstants.TABLEATTR_GENERATORDATA_LABEL), String.class); + Field E_COLOR = DSL.field(DSL.name(schema, + DBConstants.TABLENAME_GENERATORDATA_MAPCOORDINATES_EDGES, + DBConstants.TABLEATTR_GENERATORDATA_COLOR_FILL), String.class); + + List edgeBatch = new ArrayList<>(); + for (Edge e : edges) { + edgeBatch.add( + dsl.insertInto(EDGE_TABLE) + .columns(E_GENERATORID, E_FILENAME, E_FROM, E_TO, + E_NUMBER, E_LABEL, E_COLOR) + .values(id, e.filename, e.fromIndex, e.toIndex, + e.number != null ? e.number.doubleValue() : null, + e.label, colorToHex(e.color)) + ); + } + dsl.batch(edgeBatch).execute(); + } } + } + + private Color mapRGBAorStringToColor(Object colorObj, Color defaultColor) { + try { + if (colorObj == null) { + return defaultColor; + } else if (String.class.equals(colorObj.getClass())) { + return Color.decode((String) colorObj); + } else if (colorObj instanceof Map) { + Map colorObjMap = (Map) colorObj; + Number red = colorObjMap.getOrDefault("Red", 0.0); + Number green = colorObjMap.getOrDefault("Green", 0.0); + Number blue = colorObjMap.getOrDefault("Blue", 0.0); + Number alpha = colorObjMap.getOrDefault("Alpha", 0.0); + return new Color(red.floatValue(), green.floatValue(), + blue.floatValue(), alpha.floatValue()); + } + } catch (Exception ignored) {} + return defaultColor; } - public static String coordinatesListToString(List coordinates) { - if (coordinates == null) { - return null; - } + /** Converts a {@link Color} to a lowercase CSS hex string, e.g. {@code #1a2b3c}. */ + private static String colorToHex(Color c) { + return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue()); + } + public static String coordinatesListToString(List coordinates) { + if (coordinates == null) return null; StringBuilder sb = new StringBuilder(); - for (int i = 0; i < coordinates.size(); i++) { sb.append(coordinates.get(i)); - if (i < coordinates.size() - 1) { - sb.append(", "); - } + if (i < coordinates.size() - 1) sb.append(", "); } - return sb.toString(); } + // ───────────────────────────────────────────────────────────────────────── + // Inner classes + // ───────────────────────────────────────────────────────────────────────── private static class Entry { - private final String filename; - private final String label; + private final String filename; + private final String label; private final List coordinates; - private final Number scale; - private final Color fillColor; - private final Color strokeColor; - private final Color outsideColor; - - private Entry(String filename, String label, List coordinates, Number scale, Color fillColor, Color strokeColor, Color outsideColor) { - this.filename = filename; - this.label = label; - this.coordinates = coordinates; - this.scale = scale; - this.fillColor = fillColor; - this.strokeColor = strokeColor; + private final Number scale; + private final Color fillColor; + private final Color strokeColor; + private final Color outsideColor; + + private Entry(String filename, String label, List coordinates, + Number scale, Color fillColor, Color strokeColor, Color outsideColor) { + this.filename = filename; + this.label = label; + this.coordinates = coordinates; + this.scale = scale; + this.fillColor = fillColor; + this.strokeColor = strokeColor; this.outsideColor = outsideColor; } } -} + + /** + * Describes a directed edge between two vertices identified by their + * zero-based index in the {@code entries} list. + * + *

All fields except {@code fromIndex} and {@code toIndex} are optional. + * If no edges are provided in the source, the edge list stays empty + * and the edge DB table is never created, keeping full backward compatibility. + */ + private static class Edge { + /** Source vertex — zero-based index into the {@code entries} list. */ + private final int fromIndex; + /** Target vertex — zero-based index into the {@code entries} list. */ + private final int toIndex; + /** Optional numeric weight / distance / cost associated with the edge. */ + private final Number number; + /** Optional human-readable label shown alongside the edge. */ + private final String label; + /** Display color of the edge line; defaults to {@link Color#GRAY}. */ + private final Color color; + /** Source filename — kept for traceability in the DB. */ + private final String filename; + + private Edge(String filename, int fromIndex, int toIndex, + Number number, String label, Color color) { + this.filename = filename; + this.fromIndex = fromIndex; + this.toIndex = toIndex; + this.number = number; + this.label = label; + this.color = color; + } + } + + private record PointData(double x, double y) {} +} \ No newline at end of file diff --git a/src/main/java/org/texttechnologylab/udav/generators/TextFormatting.java b/src/main/java/org/texttechnologylab/udav/generators/TextFormatting.java index 3de9f870..f2b892ff 100644 --- a/src/main/java/org/texttechnologylab/udav/generators/TextFormatting.java +++ b/src/main/java/org/texttechnologylab/udav/generators/TextFormatting.java @@ -73,7 +73,7 @@ public void setup_step1() throws SQLException { SourceUIMA sourceUIMA = (SourceUIMA) source; this.UIMAsofaID = settings.getStringSettingOrDefault("sofaID", null); String sofaFile = settings.getStringSettingOrDefault("sofaFile", null); - if (sofaFile == null) { + if (sofaFile == null || sofaFile.isBlank()) { Set allFiles = sourceUIMA.determineAllSourceFiles(); settings.defineFilterListUniversalSetString("files", allFiles); FilterList filterListSourceFiles = settings.generateStringFilterList("files"); @@ -181,6 +181,12 @@ public void writeToDB() throws SQLException { Field END_SEGS = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_TYPESEGMENTS, DBConstants.TABLEATTR_GENERATORDATA_END), Integer.class); Field CAT_SEGS = DSL.field(DSL.name(schema, DBConstants.TABLENAME_GENERATORDATA_TYPESEGMENTS, DBConstants.TABLEATTR_GENERATORDATA_CATEGORY), String.class); + // Replace previous rows for this generator to keep saves idempotent. + dsl.deleteFrom(T_SEGS).where(GID_SEGS.eq(id)).execute(); + dsl.deleteFrom(T_COLOR).where(GID_COLOR.eq(id)).execute(); + dsl.deleteFrom(T_STYLE).where(GID_STYLE.eq(id)).execute(); + dsl.deleteFrom(T_TEXT).where(GID_TEXT.eq(id)).execute(); + // ---------- Insert text ---------- dsl.insertInto(T_TEXT) .columns(GID_TEXT, TXT_TEXT) diff --git a/src/main/java/org/texttechnologylab/udav/generators/sources/SourceJson.java b/src/main/java/org/texttechnologylab/udav/generators/sources/SourceJson.java index db8968bc..db5100b3 100644 --- a/src/main/java/org/texttechnologylab/udav/generators/sources/SourceJson.java +++ b/src/main/java/org/texttechnologylab/udav/generators/sources/SourceJson.java @@ -4,11 +4,18 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Getter; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.texttechnologylab.udav.db.SchemaObjectNames; import org.texttechnologylab.udav.generators.settings.GeneratorSettings; import org.texttechnologylab.udav.pipeline.JSONView; +import org.texttechnologylab.udav.sources.DBAccess; import java.io.IOException; -import java.io.InputStream; +import java.sql.Connection; +import java.sql.SQLException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -23,9 +30,9 @@ public class SourceJson extends Source { protected final JSONView singleFileJSONView; protected final Map filenameToJsonView; // TODO: Use or remove - public SourceJson(String filepath) throws IOException { + public SourceJson(String filepath, DBAccess dbAccess) throws IOException, SQLException { this.singleFileName = SOURCE_FILES_PATH + "/" + filepath.trim(); - this.singleFileJSONView = readJsonViewFromFile(singleFileName); + this.singleFileJSONView = readJsonViewFromDB(singleFileName, dbAccess); this.filenameToJsonView = null; } public SourceJson(String normalizedFilepath, JSONView jsonView) { @@ -49,6 +56,25 @@ public List> generateKeysMap(GeneratorSettings settings) { return keysMap; } + /** + * Same settings grammar as {@link #generateKeysMap(GeneratorSettings)}, but interpreted row-wise. + * Useful for list-root JSON where each list item is one logical row (e.g. edge pairs). + */ + public List> generateKeysMapRowWise(GeneratorSettings settings) { + + Map map_keysMap = (Map) settings.getMapSettingOrDefault("keysMap", null); + List> keysMap; + if (map_keysMap == null) { + map_keysMap = (Map) settings.getMapSettingOrDefault("keys", null); + keysMap = generateFlatKeysRowWise(map_keysMap); + } else { + keysMap = generateFlatKeysMapRowWise(map_keysMap); + } + addFixedKeysToKeysMap(keysMap, (Map) settings.getMapSettingOrDefault("fixedKeys", null)); + + return keysMap; + } + private void addFixedKeysToKeysMap(List> keysMap, Map fixedKeys) { if (fixedKeys == null) return; for (Map m : keysMap) { @@ -93,6 +119,50 @@ private List> generateFlatKeysMap(Map keysMa generateFlatKeysMapRecursive(keysMapRoot, singleFileJSONView, flatKeysMap); return flatKeysMap; } + + private List> generateFlatKeysRowWise(Map keysRoot) { + ArrayList> flatKeysMap = new ArrayList<>(); + if (keysRoot == null) return flatKeysMap; + if (!singleFileJSONView.isList()) return flatKeysMap; + + for (Object rowObj : singleFileJSONView.asList()) { + JSONView row = new JSONView(rowObj); + HashMap currentMap = new HashMap<>(); + for (Map.Entry entry : keysRoot.entrySet()) { + String key = entry.getKey(); + if (entry.getValue() instanceof List valueList) { + int i = 0; + HashMap innerMap = new HashMap<>(); + for (Object e : valueList) { + String value = (String) e; + innerMap.put(Integer.toString(i), getChildNode(row, value)); + i++; + } + currentMap.put(key, innerMap); + } else { + String value = (String) entry.getValue(); + currentMap.put(key, getChildNode(row, value)); + } + } + flatKeysMap.add(currentMap); + } + + return flatKeysMap; + } + + private List> generateFlatKeysMapRowWise(Map keysMapRoot) { + ArrayList> flatKeysMap = new ArrayList<>(); + if (keysMapRoot == null) return flatKeysMap; + if (!singleFileJSONView.isList()) return flatKeysMap; + + for (Object rowObj : singleFileJSONView.asList()) { + HashMap currentMap = new HashMap<>(); + generateFlatKeysMapRecursiveRowWise(keysMapRoot, new JSONView(rowObj), currentMap); + flatKeysMap.add(currentMap); + } + + return flatKeysMap; + } private void generateFlatKeysMapRecursive(Map keysMapCurrentPosition, JSONView currentPosition, ArrayList> flatKeysMap) { for (Map.Entry entry : keysMapCurrentPosition.entrySet()) { String key = entry.getKey(); @@ -175,25 +245,116 @@ private void generateFlatKeysMapRecursive(Map keysMapCurrentPosi } } - private static JSONView readJsonViewFromFile(String path) throws IOException { - try (InputStream in = SourceJson.class.getClassLoader().getResourceAsStream(path)) { - if (in == null) { - throw new IllegalArgumentException("File not found: " + path); + private void generateFlatKeysMapRecursiveRowWise(Map keysMapCurrentPosition, JSONView currentPosition, Map currentMap) { + for (Map.Entry entry : keysMapCurrentPosition.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + JSONView valuePosition = getChildView(currentPosition, key); + if (valuePosition == null) { + continue; + } + + if (value instanceof String stringValue) { + putMappedValue(currentMap, stringValue.trim(), valuePosition.getNode()); + } else if (value instanceof List listValue) { + Object rawNode = valuePosition.getNode(); + if (!(rawNode instanceof List valueNode)) { + continue; + } + int listSize = listValue.size(); + for (int i = 0; i < listSize; i++) { + String stringValue = (String) listValue.get(i); + Object mappedNode = i < valueNode.size() ? valueNode.get(i) : null; + putMappedValue(currentMap, stringValue, mappedNode); + } + } else if (value instanceof Map subMap) { + generateFlatKeysMapRecursiveRowWise((Map) subMap, valuePosition, currentMap); + } + } + } + + private static void putMappedValue(Map destination, String target, Object value) { + String trimmed = target.trim(); + if (trimmed.contains("@")) { + String[] split = trimmed.split("@", 2); + Map innerValueMap = (Map) destination.get(split[0]); + if (innerValueMap == null) { + innerValueMap = new HashMap<>(); + destination.put(split[0], innerValueMap); } + innerValueMap.put(split[1], value); + } else { + destination.put(trimmed, value); + } + } - ObjectMapper mapper = new ObjectMapper(); - JsonNode rootNode = mapper.readTree(in); - - Object rootValue; - if (rootNode.isObject()) { - rootValue = mapper.convertValue(rootNode, new TypeReference>() {}); - } else if (rootNode.isArray()) { - rootValue = mapper.convertValue(rootNode, new TypeReference>() {}); - } else { - // For primitive root nodes (string, number, boolean, null) - rootValue = mapper.convertValue(rootNode, Object.class); + private static JSONView getChildView(JSONView currentPosition, String key) { + try { + if (currentPosition.isMap()) { + return currentPosition.get(key); } - return new JSONView(rootValue); + if (currentPosition.isList()) { + int idx = Integer.parseInt(key); + return currentPosition.get(idx); + } + } catch (Exception ignored) { + return null; + } + return null; + } + + private static Object getChildNode(JSONView currentPosition, String key) { + JSONView child = getChildView(currentPosition, key); + return child != null ? child.getNode() : null; + } + + private static JSONView readJsonViewFromDB(String path, DBAccess dbAccess) throws IOException, SQLException { + if (dbAccess == null) { + throw new IllegalArgumentException("dbAccess must not be null"); + } + + String sourceFileName = path.trim(); + int slashIdx = sourceFileName.lastIndexOf('/'); + if (slashIdx >= 0 && slashIdx < sourceFileName.length() - 1) { + sourceFileName = sourceFileName.substring(slashIdx + 1); + } + + String json = readJsonString(dbAccess, sourceFileName); + if (json == null && !"public".equalsIgnoreCase(dbAccess.getSchema())) { + json = readJsonString(dbAccess, "public", sourceFileName); + } + if (json == null) { + throw new IllegalArgumentException("JSON source not found in DB table \"" + SchemaObjectNames.TABLE_JSON_DATA + "\": " + sourceFileName); + } + + ObjectMapper mapper = new ObjectMapper(); + JsonNode rootNode = mapper.readTree(json); + + Object rootValue; + if (rootNode.isObject()) { + rootValue = mapper.convertValue(rootNode, new TypeReference>() {}); + } else if (rootNode.isArray()) { + rootValue = mapper.convertValue(rootNode, new TypeReference>() {}); + } else { + rootValue = mapper.convertValue(rootNode, Object.class); + } + return new JSONView(rootValue); + } + + private static String readJsonString(DBAccess dbAccess, String sourceFileName) throws SQLException { + return readJsonString(dbAccess, dbAccess.getSchema(), sourceFileName); + } + + private static String readJsonString(DBAccess dbAccess, String schema, String sourceFileName) throws SQLException { + try (Connection connection = dbAccess.getDataSource().getConnection()) { + DSLContext dsl = DSL.using(connection); + Table table = DSL.table(DSL.name(schema, SchemaObjectNames.TABLE_JSON_DATA)); + Field fName = DSL.field(DSL.name(schema, SchemaObjectNames.TABLE_JSON_DATA, SchemaObjectNames.COL_JSON_DATA_SOURCEFILE_NAME), String.class); + Field fJson = DSL.field(DSL.name(schema, SchemaObjectNames.TABLE_JSON_DATA, SchemaObjectNames.COL_JSON_DATA_JSON), String.class); + return dsl.select(fJson).from(table).where(fName.eq(sourceFileName)).fetchOne(fJson); + } catch (org.jooq.exception.DataAccessException e) { + return null; } } } diff --git a/src/main/java/org/texttechnologylab/udav/generators/sources/SourceJsonN.java b/src/main/java/org/texttechnologylab/udav/generators/sources/SourceJsonN.java index 29259d33..e1d45322 100644 --- a/src/main/java/org/texttechnologylab/udav/generators/sources/SourceJsonN.java +++ b/src/main/java/org/texttechnologylab/udav/generators/sources/SourceJsonN.java @@ -1,16 +1,19 @@ package org.texttechnologylab.udav.generators.sources; +import org.texttechnologylab.udav.sources.DBAccess; + import java.io.IOException; -import java.util.HashMap; +import java.sql.SQLException; +import java.util.LinkedHashMap; import java.util.Map; public class SourceJsonN extends SourceJson implements SourceN { private final Map subSources; - public SourceJsonN(String filepath) throws IOException { - super(filepath); - this.subSources = new HashMap<>(); + public SourceJsonN(String filepath, DBAccess dbAccess) throws IOException, SQLException { + super(filepath, dbAccess); + this.subSources = new LinkedHashMap<>(); Map map = singleFileJSONView.asMap(); for (String key : map.keySet()) subSources.put(key, new SourceJson(singleFileName, singleFileJSONView.get(key))); } diff --git a/src/main/java/org/texttechnologylab/udav/generators/sources/SourceUIMA.java b/src/main/java/org/texttechnologylab/udav/generators/sources/SourceUIMA.java index f4cd874a..03dda4de 100644 --- a/src/main/java/org/texttechnologylab/udav/generators/sources/SourceUIMA.java +++ b/src/main/java/org/texttechnologylab/udav/generators/sources/SourceUIMA.java @@ -25,7 +25,7 @@ public SourceUIMA(String uri, DBAccess dbAccess) throws SQLException { this.uri = uri.trim(); this.dbAccess = dbAccess; try (Connection connection = dbAccess.getDataSource().getConnection()) { - DSLContext dsl = DSL.using(dbAccess.getDataSource().getConnection()); + DSLContext dsl = DSL.using(connection); TypeTableResolver resolver = new TypeTableResolver(dsl, DB_SCHEMA_UIMA); this.tableHash = resolver.tableForType(uri); } diff --git a/src/main/java/org/texttechnologylab/udav/importer/DUUIImporter.java b/src/main/java/org/texttechnologylab/udav/importer/DUUIImporter.java index 0a967478..3e1fa778 100644 --- a/src/main/java/org/texttechnologylab/udav/importer/DUUIImporter.java +++ b/src/main/java/org/texttechnologylab/udav/importer/DUUIImporter.java @@ -6,7 +6,6 @@ import org.apache.uima.resource.metadata.TypeSystemDescription; import org.apache.uima.util.XMLInputSource; import org.dkpro.core.io.xmi.XmiWriter; -import org.junit.jupiter.api.DisplayName; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; @@ -27,7 +26,10 @@ import java.io.File; import java.io.IOException; +import java.lang.reflect.Method; import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; import static org.apache.uima.fit.factory.AnalysisEngineFactory.createEngineDescription; @@ -38,9 +40,12 @@ public class DUUIImporter implements ApplicationRunner { private static final Logger LOGGER = LoggerFactory.getLogger(DUUIImporter.class); + private final DbProps db; private final DUUIImporterProps duuiProps; private final PostImportRowCounter postImportRowCounter; + private final PostImportIndexBuilder postImportIndexBuilder; + private DUUIComposer composer; private TypeSystemDescription externalTypeSystem; @@ -48,7 +53,7 @@ private void init() throws IOException, URISyntaxException, UIMAException, SAXEx DUUILuaContext ctx = new DUUILuaContext().withJsonLibrary(); composer = new DUUIComposer() - .withSkipVerification(true) + .withSkipVerification(resolveBooleanProp("skipVerification", "DUUI_IMPORTER_SKIP_VERIFICATION", false)) .withLuaContext(ctx) .withWorkers(duuiProps.workers()); @@ -58,46 +63,58 @@ private void init() throws IOException, URISyntaxException, UIMAException, SAXEx composer.addDriver(uimaDriver, dockerDriver); String tsPath = duuiProps.typeSystemPath(); + if (tsPath != null && !tsPath.isBlank()) { File tsFile = new File(tsPath); - if (tsFile.isFile()) { - LOGGER.info("Loading external type system from: {}", tsPath); - TypeSystemDescription tsd = UIMAFramework.getXMLParser() - .parseTypeSystemDescription(new XMLInputSource(tsFile)); - tsd.resolveImports(); - - composer.setInstantiatedTypeSystem(tsd); - externalTypeSystem = tsd; - } else { - LOGGER.warn("DUUI_IMPORTER_TYPE_SYSTEM_PATH is set but file not found: {}", tsPath); + + if (!tsFile.isFile()) { + throw new IOException("Configured DUUI type system file not found: " + tsPath); } + + LOGGER.info("Loading external type system from: {}", tsPath); + + TypeSystemDescription tsd = UIMAFramework.getXMLParser() + .parseTypeSystemDescription(new XMLInputSource(tsFile)); + + tsd.resolveImports(); + + composer.setInstantiatedTypeSystem(tsd); + externalTypeSystem = tsd; } } - @DisplayName("NLP") public void execute() throws Exception { + boolean storeCoveredText = resolveBooleanProp( + "storeCoveredText", + "DUUI_IMPORTER_STORE_COVERED_TEXT", + false + ); + + boolean prepareDbSchema = resolveBooleanProp( + "prepareDbSchema", + "DUUI_IMPORTER_PREPARE_DB_SCHEMA", + true + ); + + int dbScale = resolveIntProp("dbWorkers", "DUUI_IMPORTER_DB_WORKERS", 1); + DUUIFileReaderLazy corpusReader = - new DUUIFileReaderLazy(duuiProps.inputPath(), duuiProps.inputFileEnding(), 10); + new DUUIFileReaderLazy( + duuiProps.inputPath(), + duuiProps.inputFileEnding(), + resolveIntProp("readerBatchSize", "DUUI_IMPORTER_READER_BATCH_SIZE", 10) + ); DUUIAsynchronousProcessor processor = new DUUIAsynchronousProcessor(corpusReader); - // Docker NLP -// composer.add(new DUUIDockerDriver.Component("docker.texttechnologylab.org/textimager-duui-spacy-single-de_core_news_sm:0.1.4") -// .withScale(duuiProps.workers()) -// .withImageFetching() -// .build()); - - // Cleanup composer.add(new DUUIUIMADriver.Component( createEngineDescription(RemoveMetaInformation.class, externalTypeSystem)) .withScale(duuiProps.workers()) .build()); - // Debug XMI is a major bottleneck; keep it OFF by default. - // Enable by setting env DUUI_IMPORTER_DEBUG_XMI=true - boolean debugXmi = Boolean.parseBoolean(System.getenv().getOrDefault("DUUI_IMPORTER_DEBUG_XMI", "false")); - if (debugXmi) { - String target = System.getenv().getOrDefault("DUUI_IMPORTER_DEBUG_XMI_PATH", "/tmp/export"); + if (resolveBooleanProp("debugXmi", "DUUI_IMPORTER_DEBUG_XMI", false)) { + String target = resolveStringProp("debugXmiPath", "DUUI_IMPORTER_DEBUG_XMI_PATH", "/tmp/export"); + composer.add(new DUUIUIMADriver.Component( createEngineDescription( XmiWriter.class, @@ -108,11 +125,40 @@ public void execute() throws Exception { XmiWriter.PARAM_VERSION, "1.1", XmiWriter.PARAM_COMPRESSION, "GZIP" )) - .withScale(1) // do not scale disk writers + .withScale(1) + .build()); + } + + if (prepareDbSchema) { + LOGGER.info("Adding DB schema-preparation stage. DDL will run with scale=1 before parallel DB COPY writers."); + + composer.add(new DUUIUIMADriver.Component( + createEngineDescription( + JooqDatabaseWriter.class, + externalTypeSystem, + JooqDatabaseWriter.PARAM_JDBC_URL, db.getUrl(), + JooqDatabaseWriter.PARAM_DB_USER, db.getUser(), + JooqDatabaseWriter.PARAM_DB_PASS, db.getPass(), + JooqDatabaseWriter.PARAM_SCHEMA, db.getSchema(), + JooqDatabaseWriter.PARAM_BATCH_SIZE, db.getBatchSize(), + JooqDatabaseWriter.PARAM_MAX_IDENT, db.getMaxIdent(), + JooqDatabaseWriter.PARAM_SQL_DIALECT, db.getDialect(), + JooqDatabaseWriter.PARAM_PIPELINE_HASH, buildPipelineHash(), + JooqDatabaseWriter.PARAM_STORE_COVERED_TEXT, storeCoveredText, + JooqDatabaseWriter.PARAM_ALLOW_DDL, true, + JooqDatabaseWriter.PARAM_PREPARE_SCHEMA_ONLY, true + )) + .withScale(1) .build()); } - int dbScale = Math.max(1, Math.min(duuiProps.workers(), 4)); // start conservative; raise if DB can handle it + LOGGER.info( + "Adding DB COPY writer stage. dbWorkers={} storeCoveredText={} allowDdl={}", + dbScale, + storeCoveredText, + !prepareDbSchema + ); + composer.add(new DUUIUIMADriver.Component( createEngineDescription( JooqDatabaseWriter.class, @@ -123,19 +169,105 @@ public void execute() throws Exception { JooqDatabaseWriter.PARAM_SCHEMA, db.getSchema(), JooqDatabaseWriter.PARAM_BATCH_SIZE, db.getBatchSize(), JooqDatabaseWriter.PARAM_MAX_IDENT, db.getMaxIdent(), - JooqDatabaseWriter.PARAM_SQL_DIALECT, db.getDialect() + JooqDatabaseWriter.PARAM_SQL_DIALECT, db.getDialect(), + JooqDatabaseWriter.PARAM_PIPELINE_HASH, buildPipelineHash(), + JooqDatabaseWriter.PARAM_STORE_COVERED_TEXT, storeCoveredText, + JooqDatabaseWriter.PARAM_ALLOW_DDL, !prepareDbSchema, + JooqDatabaseWriter.PARAM_PREPARE_SCHEMA_ONLY, false )) .withScale(dbScale) .build()); - composer.run(processor, "Importer"); - composer.shutdown(); + try { + composer.run(processor, "Importer"); + } finally { + if (composer != null) { + composer.shutdown(); + } + } } @Override public void run(ApplicationArguments args) throws Exception { init(); execute(); + postImportIndexBuilder.buildIndexes(); postImportRowCounter.updateRowCounts(); } + + private String buildPipelineHash() { + List parts = new ArrayList<>(); + + parts.add("writerSchemaVersion=6-no-rowhash-no-pk"); + parts.add("inputEnding=" + duuiProps.inputFileEnding()); + parts.add("typeSystemPath=" + duuiProps.typeSystemPath()); + parts.add("removeMetaInformation=true"); + parts.add("debugXmi=" + resolveBooleanProp("debugXmi", "DUUI_IMPORTER_DEBUG_XMI", false)); + parts.add("storeCoveredText=" + resolveBooleanProp("storeCoveredText", "DUUI_IMPORTER_STORE_COVERED_TEXT", false)); + + String explicit = System.getenv("DUUI_IMPORTER_PIPELINE_HASH_EXTRA"); + + if (explicit != null && !explicit.isBlank()) { + parts.add("extra=" + explicit); + } + + return org.apache.commons.codec.digest.DigestUtils.sha256Hex(String.join("\n", parts)); + } + + private boolean resolveBooleanProp(String methodName, String envName, boolean defaultValue) { + Object reflected = tryCallNoArg(methodName); + + if (reflected instanceof Boolean b) return b; + if (reflected instanceof String s && !s.isBlank()) return Boolean.parseBoolean(s); + + String env = System.getenv(envName); + + if (env == null || env.isBlank()) return defaultValue; + + return Boolean.parseBoolean(env); + } + + private int resolveIntProp(String methodName, String envName, int defaultValue) { + Object reflected = tryCallNoArg(methodName); + + if (reflected instanceof Number n) return n.intValue(); + + if (reflected instanceof String s && !s.isBlank()) { + try { + return Integer.parseInt(s.trim()); + } catch (NumberFormatException ignored) { + } + } + + String env = System.getenv(envName); + + if (env == null || env.isBlank()) return defaultValue; + + try { + return Integer.parseInt(env.trim()); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + + private String resolveStringProp(String methodName, String envName, String defaultValue) { + Object reflected = tryCallNoArg(methodName); + + if (reflected instanceof String s && !s.isBlank()) return s; + + String env = System.getenv(envName); + + if (env == null || env.isBlank()) return defaultValue; + + return env; + } + + private Object tryCallNoArg(String methodName) { + try { + Method m = duuiProps.getClass().getMethod(methodName); + return m.invoke(duuiProps); + } catch (ReflectiveOperationException ignored) { + return null; + } + } } diff --git a/src/main/java/org/texttechnologylab/udav/importer/DatabaseGenerator.java b/src/main/java/org/texttechnologylab/udav/importer/DatabaseGenerator.java deleted file mode 100644 index 9ab314c7..00000000 --- a/src/main/java/org/texttechnologylab/udav/importer/DatabaseGenerator.java +++ /dev/null @@ -1,126 +0,0 @@ -// src/main/java/uni/textimager/sandbox/importer/DatabaseGenerator.java -package org.texttechnologylab.udav.importer; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.jdbc.core.JdbcTemplate; -import org.texttechnologylab.udav.importer.dialect.SqlDialect; -import org.texttechnologylab.udav.importer.service.DataInserterService; -import org.texttechnologylab.udav.importer.service.NameSanitizer; -import org.texttechnologylab.udav.importer.service.SchemaGeneratorService; -import org.texttechnologylab.udav.importer.service.XmiParserService; - -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; - -/** - * Application runner that generates database schema and populates tables from XMI files. - *

    - *
  • Reads .xmi files from configured input directory.
  • - *
  • Parses each file into EntityRecord objects.
  • - *
  • Computes maximum attribute lengths per table for schema generation.
  • - *
  • Creates or updates tables based on computed lengths.
  • - *
  • Inserts all parsed records into respective tables.
  • - *
  • Creates metadata table TableNames and records each table name.
  • - *
- */ -//@Component -@Order(Ordered.HIGHEST_PRECEDENCE) -@ConditionalOnProperty(name = "app.database-generator.enabled", havingValue = "true", matchIfMissing = true) -@Deprecated -public class DatabaseGenerator implements ApplicationRunner { - private final JdbcTemplate jdbc; - private final SqlDialect dialect; - private final XmiParserService parser; - private final NameSanitizer sanitizer; - private final SchemaGeneratorService schemaGen; - private final DataInserterService dataInserter; - - @Value("${app.input-dir}") - private String inputDir; - - /** - * Construct DatabaseGenerator with required services and JDBC components. - * - * @param jdbc JdbcTemplate for executing SQL statements - * @param dialect database-specific SQL dialect implementation - * @param parser service to parse XMI files into EntityRecord objects - * @param sanitizer utility to sanitize tag and attribute names - * @param schemaGen service to generate database schema based on attribute lengths - * @param dataInserter service to batch-insert EntityRecord instances into tables - */ - public DatabaseGenerator( - JdbcTemplate jdbc, - SqlDialect dialect, - XmiParserService parser, - NameSanitizer sanitizer, - SchemaGeneratorService schemaGen, - DataInserterService dataInserter) { - this.jdbc = jdbc; - this.dialect = dialect; - this.parser = parser; - this.sanitizer = sanitizer; - this.schemaGen = schemaGen; - this.dataInserter = dataInserter; - } - - /** - * Execute on application startup: - *
    - *
  1. Scan input directory for .xmi files.
  2. - *
  3. Parse each file and collect EntityRecord objects.
  4. - *
  5. Track maximum string lengths per table column for schema sizing.
  6. - *
  7. Generate or update tables via SchemaGeneratorService.
  8. - *
  9. Insert parsed records via DataInserterService.
  10. - *
  11. Create metadata table "TableNames" and insert table names.
  12. - *
- * - * @param args application arguments (ignored) - * @throws Exception on file, I/O or parsing errors - */ - @Override - public void run(ApplicationArguments args) throws Exception { - Map> maxLengths = new LinkedHashMap<>(); - List allRecords = new ArrayList<>(); - - try (DirectoryStream directoryStream = Files.newDirectoryStream(Path.of(inputDir), "*.xmi")) { - for (Path file : directoryStream) { - String fileName = file.getFileName().toString(); - for (EntityRecord entityRecord : parser.parse(file)) { - String table = sanitizer.toClassName(entityRecord.tag()); - maxLengths.computeIfAbsent(table, k -> new HashMap<>()) - .merge("filename", fileName.length(), Math::max); - entityRecord.attributes().forEach((raw, val) -> - maxLengths.get(table) - .merge(sanitizer.sanitize(raw), val.length(), Math::max) - ); - allRecords.add(entityRecord); - } - } - } - - schemaGen.generateSchema(maxLengths); - dataInserter.insertRecords(allRecords); - - // Insert metadata about tables - String metaTable = "TableNames"; - String metaPk = "tablenames_id"; - String ddlMeta = "CREATE TABLE IF NOT EXISTS " + metaTable + " (" - + dialect.autoIncrementPrimaryKey(metaPk) + ", " - + "table_name " + dialect.varcharType(255) + ", " - + "PRIMARY KEY(" + metaPk + "))"; - jdbc.execute(ddlMeta); - - String insertMeta = "INSERT INTO " + metaTable + "(table_name) VALUES(?)"; - List batchArgs = maxLengths.keySet().stream() - .map(name -> new Object[]{name}) - .toList(); - jdbc.batchUpdate(insertMeta, batchArgs); - } -} diff --git a/src/main/java/org/texttechnologylab/udav/importer/JooqDatabaseWriter.java b/src/main/java/org/texttechnologylab/udav/importer/JooqDatabaseWriter.java index 1c1bb69c..5675f0a3 100644 --- a/src/main/java/org/texttechnologylab/udav/importer/JooqDatabaseWriter.java +++ b/src/main/java/org/texttechnologylab/udav/importer/JooqDatabaseWriter.java @@ -19,12 +19,18 @@ import org.jooq.exception.DataAccessException; import org.jooq.impl.DSL; import org.jooq.impl.SQLDataType; +import org.postgresql.PGConnection; +import org.postgresql.copy.CopyManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.io.StringReader; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.sql.SQLException; import java.util.*; import java.util.Comparator; import java.util.concurrent.ConcurrentHashMap; @@ -34,7 +40,6 @@ import static org.jooq.impl.DSL.*; public class JooqDatabaseWriter extends JCasAnnotator_ImplBase { - public static final String PARAM_JDBC_URL = "jdbcUrl"; public static final String PARAM_DB_USER = "dbUser"; public static final String PARAM_DB_PASS = "dbPass"; @@ -42,45 +47,64 @@ public class JooqDatabaseWriter extends JCasAnnotator_ImplBase { public static final String PARAM_BATCH_SIZE = "batchSize"; public static final String PARAM_MAX_IDENT = "maxIdentifierLength"; public static final String PARAM_SQL_DIALECT = "sqlDialect"; - + public static final String PARAM_PIPELINE_HASH = "pipelineHash"; + public static final String PARAM_STORE_COVERED_TEXT = "storeCoveredText"; + public static final String PARAM_ALLOW_DDL = "allowDdl"; + public static final String PARAM_PREPARE_SCHEMA_ONLY = "prepareSchemaOnly"; private static final Logger LOGGER = LoggerFactory.getLogger(JooqDatabaseWriter.class); - - private static final Map> UIMA_PRIMITIVE_TO_SQL = Map.of( - "uima.cas.String", SQLDataType.CLOB, - "uima.cas.Integer", SQLDataType.INTEGER, - "uima.cas.Float", SQLDataType.REAL, - "uima.cas.Double", SQLDataType.DOUBLE, - "uima.cas.Boolean", SQLDataType.BOOLEAN, - "uima.cas.Long", SQLDataType.BIGINT, - "uima.cas.Short", SQLDataType.SMALLINT, - "uima.cas.Byte", SQLDataType.SMALLINT - ); - - private static final int TABLE_HASH_LEN = 16; - - private static final Set seenTsFingerprints = ConcurrentHashMap.newKeySet(); - private static final Object DDL_LOCK = new Object(); - private static final AtomicBoolean REGISTRY_READY = new AtomicBoolean(false); - + private static final Map> UIMA_PRIMITIVE_TO_SQL = Map.of("uima.cas.String", SQLDataType.CLOB, "uima.cas.Integer", SQLDataType.INTEGER, "uima.cas.Float", SQLDataType.REAL, "uima.cas.Double", SQLDataType.DOUBLE, "uima.cas.Boolean", SQLDataType.BOOLEAN, "uima.cas.Long", SQLDataType.BIGINT, "uima.cas.Short", SQLDataType.SMALLINT, "uima.cas.Byte", SQLDataType.SMALLINT); + private static final int TABLE_HASH_LEN = 8; + private static final int COPY_FLUSH_CHARS = 16 * 1024 * 1024; + private static final Map REGISTRY_READY = new ConcurrentHashMap<>(); + private static final Map DDL_LOCKS = new ConcurrentHashMap<>(); + private static final Map> SEEN_TS_FINGERPRINTS = new ConcurrentHashMap<>(); + private static final ThreadLocal SHA256 = ThreadLocal.withInitial(() -> { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + }); + private static final char[] HEX = "0123456789abcdef".toCharArray(); private final Map typeToTable = new ConcurrentHashMap<>(); private final Set createdTables = ConcurrentHashMap.newKeySet(); - + @ConfigurationParameter(name = PARAM_JDBC_URL, mandatory = true) + private String jdbcUrl; + @ConfigurationParameter(name = PARAM_DB_USER, mandatory = false) + private String dbUser; + @ConfigurationParameter(name = PARAM_DB_PASS, mandatory = false) + private String dbPass; + @ConfigurationParameter(name = PARAM_SQL_DIALECT, mandatory = false) + private String sqlDialectName; @ConfigurationParameter(name = PARAM_SCHEMA, mandatory = false, defaultValue = "public") - private String schema; - - @ConfigurationParameter(name = PARAM_BATCH_SIZE, mandatory = false, defaultValue = "1000") - private int batchSize; - + private String schema = "public"; + @ConfigurationParameter(name = PARAM_BATCH_SIZE, mandatory = false, defaultValue = "10000") + private int batchSize = 10000; @ConfigurationParameter(name = PARAM_MAX_IDENT, mandatory = false, defaultValue = "63") - private int maxIdentifierLength; - + private int maxIdentifierLength = 63; + @ConfigurationParameter(name = PARAM_PIPELINE_HASH, mandatory = false, defaultValue = "unknown") + private String pipelineHash = "unknown"; + @ConfigurationParameter(name = PARAM_STORE_COVERED_TEXT, mandatory = false, defaultValue = "false") + private boolean storeCoveredText = false; + @ConfigurationParameter(name = PARAM_ALLOW_DDL, mandatory = false, defaultValue = "true") + private boolean allowDdl = true; + @ConfigurationParameter(name = PARAM_PREPARE_SCHEMA_ONLY, mandatory = false, defaultValue = "false") + private boolean prepareSchemaOnly = false; private DSLContext dsl; private HikariDataSource dataSource; + private TypeSystem cachedTs; + private TsCache tsCache; + + private static void updateHash(MessageDigest md, String key, String value) { + md.update(key.getBytes(StandardCharsets.UTF_8)); + md.update((byte) '='); + md.update((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + md.update((byte) 0); + } - private static Iterable iterable(Iterator it) { - List out = new ArrayList<>(); - while (it.hasNext()) out.add(it.next()); - return out; + private static String featSortName(Feature f) { + String s = f.getShortName(); + return s != null ? s : f.getName(); } private static DocumentMetaData getOrCreateDocumentMeta(JCas jCas) { @@ -88,7 +112,7 @@ private static DocumentMetaData getOrCreateDocumentMeta(JCas jCas) { return DocumentMetaData.get(jCas); } catch (IllegalArgumentException e) { DocumentMetaData md = new DocumentMetaData(jCas); - md.setDocumentId(UUID.randomUUID().toString()); + md.setDocumentId(deterministicTextId(jCas)); md.setDocumentTitle("unknown"); md.setDocumentUri(null); md.addToIndexes(); @@ -101,11 +125,46 @@ private static T safe(T v, Supplier... fallbacks) { if (v != null && !(v instanceof String s && s.isBlank())) return v; for (Supplier fb : fallbacks) { T t = fb.get(); - if (t != null && (!(t instanceof String) || !((String) t).isBlank())) return t; + if (t != null && (!(t instanceof String) || !((String) t).isBlank())) { + return t; + } } return null; } + private static String deterministicDocumentId(JCas jCas, DocumentMetaData md) { + if (md != null) { + if (!isBlank(md.getDocumentId())) return stripXmiSuffix(md.getDocumentId()); + if (!isBlank(md.getDocumentUri())) return DigestUtils.sha256Hex("uri:" + md.getDocumentUri()); + if (!isBlank(md.getDocumentTitle())) return DigestUtils.sha256Hex("title:" + md.getDocumentTitle()); + } + return deterministicTextId(jCas); + } + + // ".xmi" is a serialization-format suffix, not part of a logical document identity. + // Stripping it here keeps doc_id consistent across docs whose upstream readers happen + // to set DocumentMetaData.documentId with or without the extension. + private static String stripXmiSuffix(String id) { + String s = id.trim(); + if (s.length() > 4 && s.regionMatches(true, s.length() - 4, ".xmi", 0, 4)) { + return s.substring(0, s.length() - 4); + } + return s; + } + + private static String deterministicTextId(JCas jCas) { + String text = null; + try { + text = jCas.getDocumentText(); + } catch (Throwable ignored) { + } + return DigestUtils.sha256Hex("text:" + (text == null ? "" : text)); + } + + private static String emptyToNull(String s) { + return (s == null || s.isEmpty()) ? null : s; + } + private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } @@ -131,590 +190,633 @@ private static SQLDialect resolveDialect(String explicit, String url) throws Res return SQLDialect.DEFAULT; } - private static String emptyToNull(String s) { - return (s == null || s.isEmpty()) ? null : s; + private static int hardIdentifierLimitForDialect(SQLDialect dialect) { + return switch (dialect.family()) { + case POSTGRES -> 63; + case MYSQL, MARIADB -> 64; + default -> 63; + }; } - @Override - public void initialize(UimaContext context) throws ResourceInitializationException { - super.initialize(context); + private static int normalizeIdentifierLimit(Integer configured, SQLDialect dialect) { + int hardLimit = hardIdentifierLimitForDialect(dialect); + int requested = configured == null || configured <= 0 ? hardLimit : configured; + return Math.max(16, Math.min(requested, hardLimit)); + } - String jdbcUrl = (String) context.getConfigParameterValue(PARAM_JDBC_URL); - String dbUser = (String) context.getConfigParameterValue(PARAM_DB_USER); - String dbPass = (String) context.getConfigParameterValue(PARAM_DB_PASS); + private static boolean isPgTypeCreateRace(DataAccessException e) { + String msg = e.getMessage(); + return msg != null && msg.contains("pg_type_typname_nsp_index"); + } - this.schema = (String) context.getConfigParameterValue(PARAM_SCHEMA); - this.batchSize = (Integer) context.getConfigParameterValue(PARAM_BATCH_SIZE); - this.maxIdentifierLength = (Integer) context.getConfigParameterValue(PARAM_MAX_IDENT); - String sqlDialectName = (String) context.getConfigParameterValue(PARAM_SQL_DIALECT); + private static String rootMsg(Throwable t) { + Throwable c = t; + while (c.getCause() != null && c.getCause() != c) { + c = c.getCause(); + } + return c.getMessage(); + } - if (isBlank(jdbcUrl)) { - throw new ResourceInitializationException( - new IllegalArgumentException("JooqDatabaseWriter: jdbcUrl missing.")); + private static String bytesToHex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int i = 0, j = 0; i < bytes.length; i++) { + int b = bytes[i] & 0xff; + out[j++] = HEX[b >>> 4]; + out[j++] = HEX[b & 0x0f]; } + return new String(out); + } - SQLDialect dialect = resolveDialect(sqlDialectName, jdbcUrl); - this.schema = normalizeSchemaForDialect(this.schema, dialect); + private static long advisoryLockKey(String key) { + MessageDigest md = SHA256.get(); + md.reset(); + byte[] digest = md.digest(key.getBytes(StandardCharsets.UTF_8)); + return ByteBuffer.wrap(digest).getLong(); + } - HikariConfig cfg = new HikariConfig(); - cfg.setJdbcUrl(jdbcUrl); - cfg.setUsername(dbUser); - cfg.setPassword(dbPass); + private static Set newIdentitySet() { + return Collections.newSetFromMap(new IdentityHashMap<>()); + } - // scale writers -> need enough DB connections for batching + DDL + metadata - cfg.setMaximumPoolSize(16); - cfg.setMinimumIdle(1); + private static String stringParam(UimaContext context, String name, String fallback) { + Object v = context.getConfigParameterValue(name); + if (v == null) return fallback; + String s = String.valueOf(v); + return s.isBlank() ? fallback : s; + } - // one transaction per document, no auto-commit - cfg.setAutoCommit(false); + private static int intParam(UimaContext context, String name, int fallback) { + Object v = context.getConfigParameterValue(name); + if (v == null) return fallback; + if (v instanceof Number n) return n.intValue(); + return Integer.parseInt(String.valueOf(v)); + } - cfg.setPoolName("JooqWriterPool"); + private static boolean booleanParam(UimaContext context, String name, boolean fallback) { + Object v = context.getConfigParameterValue(name); + if (v == null) return fallback; + if (v instanceof Boolean b) return b; + return Boolean.parseBoolean(String.valueOf(v)); + } + @Override + public void initialize(UimaContext context) throws ResourceInitializationException { + super.initialize(context); + this.jdbcUrl = stringParam(context, PARAM_JDBC_URL, this.jdbcUrl); + this.dbUser = stringParam(context, PARAM_DB_USER, this.dbUser); + this.dbPass = stringParam(context, PARAM_DB_PASS, this.dbPass); + this.schema = stringParam(context, PARAM_SCHEMA, this.schema); + this.sqlDialectName = stringParam(context, PARAM_SQL_DIALECT, this.sqlDialectName); + this.pipelineHash = stringParam(context, PARAM_PIPELINE_HASH, this.pipelineHash); + this.batchSize = intParam(context, PARAM_BATCH_SIZE, this.batchSize); + this.maxIdentifierLength = intParam(context, PARAM_MAX_IDENT, this.maxIdentifierLength); + this.storeCoveredText = booleanParam(context, PARAM_STORE_COVERED_TEXT, this.storeCoveredText); + this.allowDdl = booleanParam(context, PARAM_ALLOW_DDL, this.allowDdl); + this.prepareSchemaOnly = booleanParam(context, PARAM_PREPARE_SCHEMA_ONLY, this.prepareSchemaOnly); + if (isBlank(jdbcUrl)) { + throw new ResourceInitializationException(new IllegalArgumentException("JooqDatabaseWriter: jdbcUrl missing.")); + } + if (batchSize <= 0) { + throw new ResourceInitializationException(new IllegalArgumentException("batchSize must be > 0.")); + } + if (prepareSchemaOnly && !allowDdl) { + throw new ResourceInitializationException(new IllegalArgumentException("prepareSchemaOnly=true requires allowDdl=true.")); + } + SQLDialect dialect = resolveDialect(sqlDialectName, jdbcUrl); + if (dialect.family() != SQLDialect.POSTGRES) { + throw new ResourceInitializationException(new IllegalArgumentException("JooqDatabaseWriter currently supports PostgreSQL only. Detected: " + dialect)); + } + this.maxIdentifierLength = normalizeIdentifierLimit(this.maxIdentifierLength, dialect); + this.schema = normalizeSchemaForDialect(this.schema, dialect); + HikariConfig cfg = new HikariConfig(); + cfg.setJdbcUrl(jdbcUrl); + if (!isBlank(dbUser)) { + cfg.setUsername(dbUser); + } + if (dbPass != null) { + cfg.setPassword(dbPass); + } + cfg.setMaximumPoolSize(1); + cfg.setMinimumIdle(0); + cfg.setAutoCommit(false); + cfg.setPoolName("JooqWriterPool-" + Integer.toHexString(System.identityHashCode(this))); + cfg.addDataSourceProperty("reWriteBatchedInserts", "true"); + cfg.addDataSourceProperty("ApplicationName", "udav-duui-importer"); try { this.dataSource = new HikariDataSource(cfg); } catch (IllegalArgumentException e) { throw new ResourceInitializationException(e); } - - // Reduce quoting overhead; still safe if you keep identifiers sane. Settings settings = new Settings().withRenderQuotedNames(RenderQuotedNames.EXPLICIT_DEFAULT_QUOTED); this.dsl = DSL.using(this.dataSource, dialect, settings); - - ensureRegistryTablesOnce(); - } - - private void ensureRegistryTablesOnce() { - if (REGISTRY_READY.get()) return; - synchronized (DDL_LOCK) { - if (REGISTRY_READY.get()) return; - - // Run all DDL on a single dedicated connection with autoCommit=true so that - // each statement is immediately visible to the next one on the same connection. - // This avoids the "relation does not exist" error that occurs when DDL and the - // subsequent CREATE INDEX run on different pool connections with autoCommit=false. - dsl.connection(conn -> { - boolean prevAutoCommit = conn.getAutoCommit(); - conn.setAutoCommit(true); - try { - DSLContext ddl = DSL.using(conn, dsl.dialect(), dsl.settings()); - - ddl.createSchemaIfNotExists(DSL.name(schema)).execute(); - - ddl.createTableIfNotExists(DSL.name(schema, "uima_type_registry")) - .column("id", SQLDataType.BIGINT.identity(true)) - .column("uima_type_uri", SQLDataType.CLOB.nullable(false)) - .column("table_name", SQLDataType.CLOB.nullable(false)) - .column("row_count", SQLDataType.BIGINT.defaultValue(0L)) - .column("created_at", SQLDataType.TIMESTAMPWITHTIMEZONE.defaultValue(DSL.currentOffsetDateTime())) - .constraints( - DSL.constraint(DSL.name(cut("pk_uima_type_registry"))).primaryKey("id"), - DSL.constraint(DSL.name(cut("uq_type_uri"))).unique("uima_type_uri"), - DSL.constraint(DSL.name(cut("uq_table_name"))).unique("table_name") - ) - .execute(); - - ddl.createTableIfNotExists(DSL.name(schema, "documents")) - .column("doc_id", SQLDataType.CLOB.nullable(false)) - .column("uri", SQLDataType.CLOB.nullable(true)) - .column("language", SQLDataType.CLOB.nullable(true)) - .column("content_hash", SQLDataType.VARCHAR(64).nullable(true)) - .column("ts_hash", SQLDataType.VARCHAR(64).nullable(true)) - .constraints(DSL.constraint(DSL.name(cut("pk_documents"))).primaryKey("doc_id")) - .execute(); - - ddl.createTableIfNotExists(DSL.name(schema, "type_system_fingerprints")) - .column("ts_hash", SQLDataType.CLOB.nullable(false)) - .column("created_at", SQLDataType.TIMESTAMPWITHTIMEZONE.defaultValue(DSL.currentOffsetDateTime())) - .constraints(DSL.constraint(DSL.name(cut("pk_ts_fingerprint"))).primaryKey("ts_hash")) - .execute(); - - ddl.createTableIfNotExists(DSL.name(schema, "sofas")) - .column("doc_id", SQLDataType.CLOB.nullable(false)) - .column("sofa_id", SQLDataType.VARCHAR(128).nullable(false)) - .column("sofa_num", SQLDataType.INTEGER.nullable(true)) - .column("mime_type", SQLDataType.CLOB.nullable(true)) - .column("sofa_uri", SQLDataType.CLOB.nullable(true)) - .column("sofa_string", SQLDataType.CLOB.nullable(true)) - .column("sofa_hash", SQLDataType.VARCHAR(64).nullable(true)) - .column("created_at", SQLDataType.TIMESTAMPWITHTIMEZONE.defaultValue(DSL.currentOffsetDateTime())) - .constraints( - DSL.constraint(DSL.name(cut("pk_sofas"))).primaryKey("doc_id", "sofa_id") - ) - .execute(); - - ddl.createIndexIfNotExists(DSL.name(cut("idx_sofas_doc_id"))) - .on(DSL.table(DSL.name(schema, "sofas")), DSL.field(DSL.name("doc_id"))) - .execute(); - - ensureRowCountColumn(ddl); - } finally { - conn.setAutoCommit(prevAutoCommit); - } - }); - - REGISTRY_READY.set(true); - } - } - - private void ensureRowCountColumn(DSLContext ctx) { - try { - ctx.select(field(name("row_count"))) - .from(table(name(schema, "uima_type_registry"))) - .limit(1) - .fetch(); - } catch (DataAccessException e) { - if (e.getMessage() != null && e.getMessage().contains("does not exist")) { - try { - String alterSql = String.format( - "ALTER TABLE \"%s\".\"uima_type_registry\" ADD COLUMN \"row_count\" BIGINT DEFAULT 0", - schema); - ctx.execute(alterSql); - } catch (DataAccessException ignore) { - } - } + if (allowDdl) { + ensureRegistryTablesOnce(); } } @Override public void process(JCas jCas) { - // one transaction per document + long t0 = System.nanoTime(); + TypeSystem ts = jCas.getTypeSystem(); + TsCache cache = getOrBuildTsCache(ts); + ensureTablesForTypeSystem(cache); + long t1 = System.nanoTime(); + if (prepareSchemaOnly) { + LOGGER.debug("[schema-prep] TypeSystem prepared. tsHash={} ddlMs={}", cache.tsHash, (t1 - t0) / 1_000_000); + return; + } + DocumentMetaData md = getOrCreateDocumentMeta(jCas); + final String docId = deterministicDocumentId(jCas, md); + final String uri = safe(md.getDocumentUri(), md::getDocumentTitle, () -> docId); + final String lang = jCas.getDocumentLanguage(); + final Map sofas = collectSofas(jCas); + final String contentHash = computeContentHashFromSofas(sofas); + long t2 = System.nanoTime(); + DocumentState documentState = getDocumentState(dsl, docId, cache.tsHash, contentHash, pipelineHash); + if (documentState.upToDate()) { + LOGGER.info("[skip] Document '{}' is up-to-date, skipping.", docId); + return; + } + long t3 = System.nanoTime(); + LOGGER.info("[process] Document '{}' needs (re)import. ddlMs={} extractMs={} skipCheckMs={}", docId, (t1 - t0) / 1_000_000, (t2 - t1) / 1_000_000, (t3 - t2) / 1_000_000); + final Map sofasBySofaId = sofas; + final List typeMetas = cache.types; dsl.transaction(conf -> { DSLContext tx = DSL.using(conf); - - TypeSystem ts = jCas.getTypeSystem(); - ensureTablesForTypeSystem(tx, ts); - - DocumentMetaData md = getOrCreateDocumentMeta(jCas); - String docId = safe(md.getDocumentId(), md::getDocumentUri, md::getDocumentTitle, () -> UUID.randomUUID().toString()); - String uri = safe(md.getDocumentUri(), md::getDocumentTitle, () -> docId); - String lang = jCas.getDocumentLanguage(); - - String tsHash = computeTypeSystemHash(ts); - - // collect sofa data from the CAS in-memory — no DB writes yet. - Map sofas = collectSofas(jCas); - String contentHash = computeContentHashFromSofas(tsHash, sofaHashMap(sofas)); - - LOGGER.debug("[skip-check] docId='{}' tsHash='{}' contentHash='{}'", docId, tsHash, contentHash); - if (isDocumentUpToDate(tx, docId, tsHash, contentHash)) { - LOGGER.info("[skip] Document '{}' is up-to-date, skipping.", docId); + acquireDocumentLock(tx, docId); + DocumentState lockedState = getDocumentState(tx, docId, cache.tsHash, contentHash, pipelineHash); + if (lockedState.upToDate()) { + LOGGER.info("[skip] Document '{}' became up-to-date after lock, skipping.", docId); return; } - LOGGER.info("[process] Document '{}' needs (re)import.", docId); - - // only write to the DB if the document actually needs (re)importing. + if (lockedState.exists()) { + deleteExistingRowsForDocument(tx, docId); + } upsertSofas(tx, docId, sofas); - upsertDocument(tx, docId, uri, lang, tsHash, contentHash); - - for (Type t : iterable(ts.getTypeIterator())) { - if (isSkippableType(t)) continue; - - String tableNameHash = typeToTable.get(t.getName()); - if (tableNameHash == null) continue; - - String colRowHash = sysColName(tableNameHash, "row_hash"); - String colDocId = sysColName(tableNameHash, "doc_id"); - String colSofaId = sysColName(tableNameHash, "sofa_id"); - String colBegin = sysColName(tableNameHash, "fs_begin"); - String colEnd = sysColName(tableNameHash, "fs_end"); - String colText = sysColName(tableNameHash, "covered_text"); - String colFsJson = sysColName(tableNameHash, "fs_json"); - - boolean isAnno = ts.subsumes(ts.getType("uima.tcas.Annotation"), t); - - if (isAnno) { - var idx = jCas.getCas().getAnnotationIndex(t); - if (idx == null || idx.isEmpty()) continue; - - String docText = jCas.getDocumentText(); - int docLength = docText != null ? docText.length() : 0; - - List batch = new ArrayList<>(Math.min(idx.size(), batchSize)); - for (AnnotationFS fs : idx) { - String sofaId = sofaIdForFs(fs); - - Map, Object> values = new LinkedHashMap<>(); - values.put(field(name(colRowHash)), computeRowHash(ts, t, docId, tableNameHash, fs)); - values.put(field(name(colDocId)), docId); - values.put(field(name(colSofaId)), sofaId); - values.put(field(name(colBegin)), fs.getBegin()); - values.put(field(name(colEnd)), fs.getEnd()); - values.put(field(name(colText)), safeCoveredText(docText, docLength, fs.getBegin(), fs.getEnd())); - - for (Feature f : t.getFeatures()) { - if (!isPrimitive(f)) continue; - values.putIfAbsent(field(name(featColName(tableNameHash, f))), - FeatureJsonSerializer.readPrimitive(fs, f)); + upsertDocument(tx, docId, uri, lang, cache.tsHash, contentHash, pipelineHash); + org.apache.uima.cas.CAS base = jCas.getCas(); + List views = new ArrayList<>(); + for (Iterator vit = base.getViewIterator(); vit.hasNext(); ) { + views.add(vit.next()); + } + for (TypeMeta meta : typeMetas) { + if (meta.tableNameHash == null) continue; + long typeStart = System.nanoTime(); + int rowsForType = 0; + CopyBatch copy = new CopyBatch(tx, meta.tableNameHash, copyColumnNames(meta.tableNameHash, meta.isAnno, meta.primFeats)); + Object[] row = new Object[meta.bindCount]; + Object[] featValues = new Object[meta.primFeats.size()]; + Set seenAnnoFs = meta.isAnno ? newIdentitySet() : null; + Set seenGenericFs = meta.isAnno ? null : newIdentitySet(); + for (org.apache.uima.cas.CAS view : views) { + if (meta.isAnno) { + var idx = view.getAnnotationIndex(meta.type); + if (idx == null || idx.size() == 0) continue; + SofaData sd = sofaDataForView(sofasBySofaId, view); + String docText = sd != null ? sd.text() : view.getDocumentText(); + int docLength = docText != null ? docText.length() : 0; + for (AnnotationFS fs : idx) { + if (!fs.getType().getName().equals(meta.typeName)) continue; + if (!seenAnnoFs.add(fs)) continue; + for (int i = 0; i < meta.primFeats.size(); i++) { + featValues[i] = FeatureJsonSerializer.readPrimitive(fs, meta.primFeats.get(i)); + } + String fsViewName = sofaIdForFs(fs); + int begin = fs.getBegin(); + int end = fs.getEnd(); + row[0] = docId; + row[1] = fsViewName; + row[2] = begin; + row[3] = end; + int offset; + if (storeCoveredText) { + row[4] = safeCoveredText(docText, docLength, begin, end); + offset = 5; + } else { + offset = 4; + } + for (int i = 0; i < meta.primFeats.size(); i++) { + row[offset + i] = featValues[i]; + } + copy.add(row); + rowsForType++; } - - values.put(field(name(colFsJson)), FeatureJsonSerializer.toJsonPrimitivesOnly(fs)); - - batch.add(insertIgnore(tx, tableNameHash, values, colRowHash)); - if (batch.size() >= batchSize) { - tx.batch(batch).execute(); - batch.clear(); + } else { + var it = view.getIndexRepository().getAllIndexedFS(meta.type); + if (it == null) continue; + while (it.hasNext()) { + org.apache.uima.cas.FeatureStructure fs = it.next(); + if (!fs.getType().getName().equals(meta.typeName)) continue; + if (!seenGenericFs.add(fs)) continue; + for (int i = 0; i < meta.primFeats.size(); i++) { + featValues[i] = FeatureJsonSerializer.readPrimitive(fs, meta.primFeats.get(i)); + } + String fsViewName = sofaIdForFs(fs); + row[0] = docId; + row[1] = fsViewName; + for (int i = 0; i < meta.primFeats.size(); i++) { + row[2 + i] = featValues[i]; + } + copy.add(row); + rowsForType++; } } - if (!batch.isEmpty()) tx.batch(batch).execute(); - - } else { - var it = jCas.getCas().getIndexRepository().getAllIndexedFS(t); - if (it == null) continue; - - List batch = new ArrayList<>(batchSize); - it.forEachRemaining(fs -> { - String sofaId = sofaIdForFs(fs); - - Map, Object> values = new LinkedHashMap<>(); - values.put(field(name(colRowHash)), computeRowHash(ts, t, docId, tableNameHash, fs)); - values.put(field(name(colDocId)), docId); - values.put(field(name(colSofaId)), sofaId); - - for (Feature f : t.getFeatures()) { - if (!isPrimitive(f)) continue; - values.put(field(name(featColName(tableNameHash, f))), - FeatureJsonSerializer.readPrimitive(fs, f)); - } - values.put(field(name(colFsJson)), FeatureJsonSerializer.toJsonPrimitivesOnly(fs)); - - batch.add(insertIgnore(tx, tableNameHash, values, colRowHash)); - if (batch.size() >= batchSize) { - tx.batch(batch).execute(); - batch.clear(); - } - }); - if (!batch.isEmpty()) tx.batch(batch).execute(); + } + copy.flush(); + if (rowsForType > 0) { + LOGGER.debug("[type-import-copy] doc={} type={} table={} rows={} ms={}", docId, meta.typeName, meta.tableNameHash, rowsForType, (System.nanoTime() - typeStart) / 1_000_000); } } }); + LOGGER.info("[done] Document '{}' imported in {}ms.", docId, (System.nanoTime() - t0) / 1_000_000); } - private void ensureTablesForTypeSystem(DSLContext ctx, TypeSystem ts) { - String tsHash = computeTypeSystemHash(ts); - if (seenTsFingerprints.contains(tsHash)) return; - - synchronized (DDL_LOCK) { - if (seenTsFingerprints.contains(tsHash)) return; - - if (fingerprintExists(ctx, tsHash)) { - preloadTypeToTableFromRegistry(ctx, ts); - seenTsFingerprints.add(tsHash); - return; - } - - for (Type t : iterable(ts.getTypeIterator())) { - if (isSkippableType(t)) continue; - - String uimaType = t.getName(); - String tableNameHash = typeToTable.computeIfAbsent(uimaType, this::toSafeTableName); - if (createdTables.contains(tableNameHash)) continue; - - String pkCol = pkColName(tableNameHash); - String colRowH = sysColName(tableNameHash, "row_hash"); - String colDoc = sysColName(tableNameHash, "doc_id"); - String colSofa = sysColName(tableNameHash, "sofa_id"); - String colBegin = sysColName(tableNameHash, "fs_begin"); - String colEnd = sysColName(tableNameHash, "fs_end"); - String colText = sysColName(tableNameHash, "covered_text"); - String colFsJs = sysColName(tableNameHash, "fs_json"); - - List> cols = new ArrayList<>(); - cols.add(field(name(pkCol), SQLDataType.BIGINT.identity(true))); - cols.add(field(name(colRowH), SQLDataType.VARCHAR(64).nullable(false))); - cols.add(field(name(colDoc), SQLDataType.CLOB.nullable(false))); - cols.add(field(name(colSofa), SQLDataType.VARCHAR(128).nullable(false))); - - boolean isAnno = ts.subsumes(ts.getType("uima.tcas.Annotation"), t); - if (isAnno) { - cols.add(field(name(colBegin), SQLDataType.INTEGER.nullable(false))); - cols.add(field(name(colEnd), SQLDataType.INTEGER.nullable(false))); - cols.add(field(name(colText), SQLDataType.CLOB.nullable(true))); + private void appendCopyTextValue(StringBuilder sb, Object value) { + if (value == null) { + sb.append("\\N"); + return; + } + String s; + if (value instanceof Boolean b) { + s = b ? "true" : "false"; + } else { + s = String.valueOf(value); + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '\\' -> sb.append("\\\\"); + case '\t' -> sb.append("\\t"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\0' -> { } + default -> sb.append(c); + } + } + } - for (Feature f : t.getFeatures()) { - if (!isPrimitive(f)) continue; + private List copyColumnNames(String tableNameHash, boolean isAnno, List primFeats) { + List cols = new ArrayList<>(); + cols.add(sysColName(tableNameHash, "doc_id")); + cols.add(sysColName(tableNameHash, "sofa_id")); + if (isAnno) { + cols.add(sysColName(tableNameHash, "fs_begin")); + cols.add(sysColName(tableNameHash, "fs_end")); + if (storeCoveredText) { + cols.add(sysColName(tableNameHash, "covered_text")); + } + } + for (Feature f : primFeats) { + cols.add(featColName(tableNameHash, f)); + } + return cols; + } - DataType dt = mapPrimitiveType(f.getRange().getName()).nullable(true); - cols.add(field(name(featColName(tableNameHash, f)), dt)); - } + private void acquireDocumentLock(DSLContext tx, String docId) { + tx.execute("SELECT pg_advisory_xact_lock(?)", advisoryLockKey("uima-doc:" + docId)); + } - cols.add(field(name(colFsJs), SQLDataType.JSON.nullable(true))); - - ctx.createTableIfNotExists(name(schema, tableNameHash)) - .columns(cols) - .constraints( - constraint(name(cut("pk_" + tableNameHash))).primaryKey(field(name(pkCol))), - constraint(name(cut("uq_" + tableNameHash + "_rowhash"))).unique(field(name(colRowH))) - ) - .execute(); - - if (isAnno) { - ctx.createIndexIfNotExists(name(cut("idx_" + tableNameHash + "_doc_sofa_begin"))) - .on(table(name(schema, tableNameHash)), - field(name(colDoc)), field(name(colSofa)), field(name(colBegin))) - .execute(); - } else { - ctx.createIndexIfNotExists(name(cut("idx_" + tableNameHash + "_doc_sofa"))) - .on(table(name(schema, tableNameHash)), - field(name(colDoc)), field(name(colSofa))) - .execute(); - } + private void deleteExistingRowsForDocument(DSLContext tx, String docId) { + for (String tableName : existingRegisteredTypeTables(tx)) { + Field docField = field(name(sysColName(tableName, "doc_id"))); + tx.deleteFrom(table(name(schema, tableName))).where(docField.eq(docId)).execute(); + } + tx.deleteFrom(table(name(schema, "sofas"))).where(field(name("doc_id")).eq(docId)).execute(); + } - upsertTypeRegistry(ctx, uimaType, tableNameHash); - createdTables.add(tableNameHash); + private Set existingRegisteredTypeTables(DSLContext ctx) { + Set registered = new TreeSet<>(); + try { + List registryTables = ctx.select(field(name("table_name"), String.class)).from(table(name(schema, "uima_type_registry"))).fetch(field(name("table_name"), String.class)); + registered.addAll(registryTables); + } catch (DataAccessException e) { + if (!allowDdl) { + throw new IllegalStateException("DDL is disabled and uima_type_registry cannot be read.", e); } - - insertFingerprint(ctx, tsHash); - seenTsFingerprints.add(tsHash); + throw e; + } + registered.addAll(typeToTable.values()); + if (registered.isEmpty()) { + return registered; } + return new TreeSet<>(ctx.select(field(name("table_name"), String.class)).from(table(name("information_schema", "tables"))).where(field(name("table_schema"), String.class).eq(schema)).and(field(name("table_name"), String.class).in(registered)).fetch(field(name("table_name"), String.class))); } - private boolean isSkippableType(Type t) { - String n = t.getName(); - return n.startsWith("uima.cas.") && !n.equals("uima.tcas.Annotation"); + private RegistryKey registryKey() { + return new RegistryKey(jdbcUrl, schema); } - private boolean isPrimitive(Feature f) { - return UIMA_PRIMITIVE_TO_SQL.containsKey(f.getRange().getName()); + private Object ddlLock() { + return DDL_LOCKS.computeIfAbsent(registryKey(), ignored -> new Object()); } - private DataType mapPrimitiveType(String rangeName) { - return UIMA_PRIMITIVE_TO_SQL.getOrDefault(rangeName, SQLDataType.CLOB); + private Set seenTsFingerprints() { + return SEEN_TS_FINGERPRINTS.computeIfAbsent(registryKey(), ignored -> ConcurrentHashMap.newKeySet()); } - private String toSafeTableName(String uimaTypeName) { - return tableHash(uimaTypeName); + private AtomicBoolean registryReadyFlag() { + return REGISTRY_READY.computeIfAbsent(registryKey(), ignored -> new AtomicBoolean(false)); } - private String normalizeSchemaForDialect(String schema, SQLDialect dialect) { - String s = (schema == null || schema.isBlank()) ? "public" : schema; - if (dialect.family() == SQLDialect.H2 && "public".equalsIgnoreCase(s)) return "PUBLIC"; - if (dialect.family() == SQLDialect.POSTGRES) return s.toLowerCase(Locale.ROOT); - return s; + private void ensureRegistryTablesOnce() { + AtomicBoolean ready = registryReadyFlag(); + if (ready.get()) return; + synchronized (ddlLock()) { + if (ready.get()) return; + dsl.connection(conn -> { + boolean prevAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(true); + try { + DSLContext ddl = DSL.using(conn, dsl.dialect(), dsl.settings()); + ddl.createSchemaIfNotExists(DSL.name(schema)).execute(); + ddl.createTableIfNotExists(DSL.name(schema, "uima_type_registry")).column("id", SQLDataType.BIGINT.identity(true)).column("uima_type_uri", SQLDataType.CLOB.nullable(false)).column("table_name", SQLDataType.VARCHAR(maxIdentifierLength).nullable(false)).column("row_count", SQLDataType.BIGINT.defaultValue(0L)).column("created_at", SQLDataType.TIMESTAMPWITHTIMEZONE.defaultValue(currentOffsetDateTime())).constraints(constraint(DSL.name(cutWithHash("pk_uima_type_registry"))).primaryKey("id"), constraint(DSL.name(cutWithHash("uq_type_uri"))).unique("uima_type_uri"), constraint(DSL.name(cutWithHash("uq_table_name"))).unique("table_name")).execute(); + ddl.createTableIfNotExists(DSL.name(schema, "documents")).column("doc_id", SQLDataType.VARCHAR(512).nullable(false)).column("uri", SQLDataType.CLOB.nullable(true)).column("language", SQLDataType.VARCHAR(32).nullable(true)).column("content_hash", SQLDataType.VARCHAR(64).nullable(true)).column("ts_hash", SQLDataType.VARCHAR(64).nullable(true)).column("pipeline_hash", SQLDataType.VARCHAR(64).nullable(true)).constraints(constraint(DSL.name(cutWithHash("pk_documents"))).primaryKey("doc_id")).execute(); + ddl.createTableIfNotExists(DSL.name(schema, "type_system_fingerprints")).column("ts_hash", SQLDataType.VARCHAR(64).nullable(false)).column("created_at", SQLDataType.TIMESTAMPWITHTIMEZONE.defaultValue(currentOffsetDateTime())).constraints(constraint(DSL.name(cutWithHash("pk_ts_fingerprint"))).primaryKey("ts_hash")).execute(); + ddl.createTableIfNotExists(DSL.name(schema, "sofas")).column("doc_id", SQLDataType.VARCHAR(512).nullable(false)).column("sofa_id", SQLDataType.VARCHAR(128).nullable(false)).column("sofa_num", SQLDataType.INTEGER.nullable(true)).column("mime_type", SQLDataType.CLOB.nullable(true)).column("sofa_uri", SQLDataType.CLOB.nullable(true)).column("sofa_string", SQLDataType.CLOB.nullable(true)).column("sofa_hash", SQLDataType.VARCHAR(64).nullable(true)).column("created_at", SQLDataType.TIMESTAMPWITHTIMEZONE.defaultValue(currentOffsetDateTime())).constraints(constraint(DSL.name(cutWithHash("pk_sofas"))).primaryKey("doc_id", "sofa_id")).execute(); + ddl.createIndexIfNotExists(DSL.name(cutWithHash("idx_sofas_doc_id"))).on(DSL.table(DSL.name(schema, "sofas")), DSL.field(DSL.name("doc_id"))).execute(); + ensureCompatibilityColumns(ddl); + } finally { + conn.setAutoCommit(prevAutoCommit); + } + }); + ready.set(true); + } } - private String sanitizeIdent(String s) { - return (s == null ? "" : s.replaceAll("[^A-Za-z0-9_]", "_").toLowerCase(Locale.ROOT)); + private void ensureCompatibilityColumns(DSLContext ctx) { + ctx.execute("ALTER TABLE " + q(schema) + "." + q("uima_type_registry") + " ADD COLUMN IF NOT EXISTS " + q("row_count") + " BIGINT DEFAULT 0"); + ctx.execute("ALTER TABLE " + q(schema) + "." + q("documents") + " ADD COLUMN IF NOT EXISTS " + q("pipeline_hash") + " VARCHAR(64)"); } - private String cut(String s) { - return s.length() <= maxIdentifierLength ? s : s.substring(0, maxIdentifierLength); + private void ensureTablesForTypeSystem(TsCache cache) { + Set seenTs = seenTsFingerprints(); + if (!allowDdl) { + preloadTypeToTableFromRegistry(dsl, cache); + seenTs.add(cache.tsHash); + resolveTableHashesIntoCache(cache, true); + return; + } + if (seenTs.contains(cache.tsHash) && hasTableMappingsForAllTypes(cache)) { + resolveTableHashesIntoCache(cache, false); + return; + } + synchronized (ddlLock()) { + if (seenTs.contains(cache.tsHash) && hasTableMappingsForAllTypes(cache)) { + resolveTableHashesIntoCache(cache, false); + return; + } + dsl.connection(conn -> { + boolean prevAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(true); + try { + DSLContext ctx = DSL.using(conn, dsl.dialect(), dsl.settings()); + acquireSchemaDdlLock(ctx); + try { + runTypeSystemDDL(ctx, cache); + } finally { + releaseSchemaDdlLock(ctx); + } + } finally { + conn.setAutoCommit(prevAutoCommit); + } + }); + resolveTableHashesIntoCache(cache, true); + } } - private String tableHash(String uimaTypeName) { - String h = DigestUtils.sha256Hex(uimaTypeName).toLowerCase(Locale.ROOT); - return cut(h.substring(0, Math.min(TABLE_HASH_LEN, h.length()))); + private boolean hasTableMappingsForAllTypes(TsCache cache) { + for (TypeMeta meta : cache.types) { + if (meta.tableNameHash == null && !typeToTable.containsKey(meta.typeName)) { + return false; + } + } + return true; } - private String pkColName(String tableHash) { - return cut(tableHash + "_row_id"); + private void acquireSchemaDdlLock(DSLContext ctx) { + ctx.execute("SELECT pg_advisory_lock(?)", advisoryLockKey("uima-ddl:" + schema)); } - private String sysColName(String tableHash, String base) { - return cut(tableHash + "_" + sanitizeIdent(base)); + private void releaseSchemaDdlLock(DSLContext ctx) { + ctx.execute("SELECT pg_advisory_unlock(?)", advisoryLockKey("uima-ddl:" + schema)); } - private String featColName(String tableHash, Feature f) { - String base = sanitizeIdent(f.getShortName() != null ? f.getShortName() : f.getName()); - return cut(tableHash + "_f_" + base); + private void resolveTableHashesIntoCache(TsCache cache, boolean requireAll) { + if (cache.types.isEmpty()) return; + List resolved = new ArrayList<>(cache.types.size()); + List missing = new ArrayList<>(); + for (TypeMeta meta : cache.types) { + String hash = meta.tableNameHash != null ? meta.tableNameHash : typeToTable.get(meta.typeName); + if (hash == null) { + if (requireAll) missing.add(meta.typeName); + continue; + } + if (Objects.equals(meta.tableNameHash, hash)) { + resolved.add(meta); + } else { + resolved.add(new TypeMeta(meta.type, meta.isAnno, meta.primFeats, hash, storeCoveredText)); + } + } + if (!missing.isEmpty()) { + throw new IllegalStateException("DDL is disabled or schema registry is incomplete. Missing DB table mappings for UIMA types. Examples: " + missing.stream().limit(10).toList()); + } + cache.types.clear(); + cache.types.addAll(resolved); } - private String computeTypeSystemHash(TypeSystem ts) { - List parts = new ArrayList<>(); - for (Type t : iterable(ts.getTypeIterator())) { - if (isSkippableType(t)) continue; + private void runTypeSystemDDL(DSLContext ctx, TsCache cache) { + Set seenTs = seenTsFingerprints(); + if (fingerprintExists(ctx, cache.tsHash)) { + preloadTypeToTableFromRegistry(ctx, cache); + seenTs.add(cache.tsHash); + return; + } + for (TypeMeta meta : cache.types) { + String uimaType = meta.typeName; + String tableNameHash = typeToTable.computeIfAbsent(uimaType, this::toSafeTableName); + String colDoc = sysColName(tableNameHash, "doc_id"); + String colSofa = sysColName(tableNameHash, "sofa_id"); + String colBegin = sysColName(tableNameHash, "fs_begin"); + String colEnd = sysColName(tableNameHash, "fs_end"); + String colText = sysColName(tableNameHash, "covered_text"); + if (!createdTables.contains(tableNameHash)) { + List> cols = new ArrayList<>(); + cols.add(field(name(colDoc), SQLDataType.VARCHAR(512).nullable(false))); + cols.add(field(name(colSofa), SQLDataType.VARCHAR(128).nullable(false))); + if (meta.isAnno) { + cols.add(field(name(colBegin), SQLDataType.INTEGER.nullable(false))); + cols.add(field(name(colEnd), SQLDataType.INTEGER.nullable(false))); + if (storeCoveredText) { + cols.add(field(name(colText), SQLDataType.CLOB.nullable(true))); + } + } + for (Feature f : meta.primFeats) { + DataType dt = mapPrimitiveType(f.getRange().getName()).nullable(true); + cols.add(field(name(featColName(tableNameHash, f)), dt)); + } + try { + ctx.createTableIfNotExists(name(schema, tableNameHash)).columns(cols).execute(); + } catch (DataAccessException e) { + if (!isPgTypeCreateRace(e)) throw e; + LOGGER.debug("Ignoring PostgreSQL CREATE TABLE type race for table {}: {}", tableNameHash, rootMsg(e)); + } + } + ensureTypeCompatibilityColumns(ctx, tableNameHash, meta); + // Secondary indexes (idx_*_doc_sofa[_begin]) are intentionally NOT created here. + // PostImportIndexBuilder builds them once after all COPY work finishes — bulk-built + // btrees are denser and faster than maintaining them incrementally during COPY. + upsertTypeRegistry(ctx, uimaType, tableNameHash); + createdTables.add(tableNameHash); + } + insertFingerprint(ctx, cache.tsHash); + seenTs.add(cache.tsHash); + } - boolean isAnno = ts.subsumes(ts.getType("uima.tcas.Annotation"), t); - List feats = new ArrayList<>(); - for (Feature f : t.getFeatures()) { - String rn = f.getRange().getName(); - if (!UIMA_PRIMITIVE_TO_SQL.containsKey(rn)) continue; - String fname = (f.getShortName() != null ? f.getShortName() : f.getName()); - feats.add(fname + ":" + rn); + private void ensureTypeCompatibilityColumns(DSLContext ctx, String tableNameHash, TypeMeta meta) { + ctx.execute("ALTER TABLE " + q(schema) + "." + q(tableNameHash) + " ADD COLUMN IF NOT EXISTS " + q(sysColName(tableNameHash, "doc_id")) + " VARCHAR(512)"); + ctx.execute("ALTER TABLE " + q(schema) + "." + q(tableNameHash) + " ADD COLUMN IF NOT EXISTS " + q(sysColName(tableNameHash, "sofa_id")) + " VARCHAR(128)"); + if (meta.isAnno) { + ctx.execute("ALTER TABLE " + q(schema) + "." + q(tableNameHash) + " ADD COLUMN IF NOT EXISTS " + q(sysColName(tableNameHash, "fs_begin")) + " INTEGER"); + ctx.execute("ALTER TABLE " + q(schema) + "." + q(tableNameHash) + " ADD COLUMN IF NOT EXISTS " + q(sysColName(tableNameHash, "fs_end")) + " INTEGER"); + if (storeCoveredText) { + ctx.execute("ALTER TABLE " + q(schema) + "." + q(tableNameHash) + " ADD COLUMN IF NOT EXISTS " + q(sysColName(tableNameHash, "covered_text")) + " TEXT"); } - Collections.sort(feats); - parts.add(t.getName() + "|" + (isAnno ? "A" : "F") + "|" + String.join(",", feats)); } - Collections.sort(parts); - return DigestUtils.sha256Hex(String.join("\n", parts)); + for (Feature f : meta.primFeats) { + ctx.execute("ALTER TABLE " + q(schema) + "." + q(tableNameHash) + " ADD COLUMN IF NOT EXISTS " + q(featColName(tableNameHash, f)) + " " + postgresTypeSql(f.getRange().getName())); + } + } + + private String postgresTypeSql(String rangeName) { + return switch (rangeName) { + case "uima.cas.String" -> "TEXT"; + case "uima.cas.Integer" -> "INTEGER"; + case "uima.cas.Float" -> "REAL"; + case "uima.cas.Double" -> "DOUBLE PRECISION"; + case "uima.cas.Boolean" -> "BOOLEAN"; + case "uima.cas.Long" -> "BIGINT"; + case "uima.cas.Short", "uima.cas.Byte" -> "SMALLINT"; + default -> "TEXT"; + }; + } + + private DataType mapPrimitiveType(String rangeName) { + return UIMA_PRIMITIVE_TO_SQL.getOrDefault(rangeName, SQLDataType.CLOB); } private boolean fingerprintExists(DSLContext ctx, String tsHash) { - Integer cnt = ctx.selectCount() - .from(table(name(schema, "type_system_fingerprints"))) - .where(field("ts_hash").eq(tsHash)) - .fetchOne(0, Integer.class); + Integer cnt = ctx.selectCount().from(table(name(schema, "type_system_fingerprints"))).where(field(name("ts_hash")).eq(tsHash)).fetchOne(0, Integer.class); return cnt != null && cnt > 0; } private void insertFingerprint(DSLContext ctx, String tsHash) { - if (ctx.dialect().family() == SQLDialect.POSTGRES) { - ctx.insertInto(table(name(schema, "type_system_fingerprints"))) - .columns(field("ts_hash")) - .values(tsHash) - .onConflict(field("ts_hash")).doNothing() - .execute(); - } else { - try { - ctx.insertInto(table(name(schema, "type_system_fingerprints"))) - .columns(field("ts_hash")) - .values(tsHash) - .execute(); - } catch (DataAccessException ignore) { - } - } + ctx.insertInto(table(name(schema, "type_system_fingerprints"))).columns(field(name("ts_hash"))).values(tsHash).onConflict(field(name("ts_hash"))).doNothing().execute(); } - private void preloadTypeToTableFromRegistry(DSLContext ctx, TypeSystem ts) { - List typeNames = new ArrayList<>(); - for (Type t : iterable(ts.getTypeIterator())) if (!isSkippableType(t)) typeNames.add(t.getName()); - if (typeNames.isEmpty()) return; - - var r = ctx.select(field("uima_type_uri", String.class), field("table_name", String.class)) - .from(table(name(schema, "uima_type_registry"))) - .where(field("uima_type_uri").in(typeNames)) - .fetch(); - - for (var rec : r) { - String uri = rec.get(0, String.class); - String tbl = rec.get(1, String.class); - if (uri != null && tbl != null) typeToTable.put(uri, tbl); + private void preloadTypeToTableFromRegistry(DSLContext ctx, TsCache cache) { + if (cache.types.isEmpty()) return; + List typeNames = new ArrayList<>(cache.types.size()); + for (TypeMeta meta : cache.types) { + typeNames.add(meta.typeName); } - } - - private boolean isDocumentUpToDate(DSLContext ctx, String docId, String tsHash, String contentHash) { - var rec = ctx.select(field("ts_hash", String.class), field("content_hash", String.class)) - .from(table(name(schema, "documents"))) - .where(field("doc_id").eq(docId)) - .fetchOne(); - if (rec == null) { - LOGGER.debug("[skip-check] docId='{}' → not found in documents table.", docId); - return false; - } - String storedTs = rec.get(0, String.class); - String storedContent = rec.get(1, String.class); - boolean match = Objects.equals(tsHash, storedTs) && Objects.equals(contentHash, storedContent); - LOGGER.debug("[skip-check] docId='{}' stored ts='{}' content='{}' → match={}", - docId, storedTs, storedContent, match); - return match; - } - - private void upsertDocument(DSLContext ctx, String docId, String uri, String lang, String tsHash, String contentHash) { - switch (ctx.dialect().family()) { - case POSTGRES -> ctx.insertInto(table(name(schema, "documents"))) - .columns(field("doc_id"), field("uri"), field("language"), field("ts_hash"), field("content_hash")) - .values(docId, uri, lang, tsHash, contentHash) - .onConflict(field("doc_id")).doUpdate() - .set(field("uri"), uri) - .set(field("language"), lang) - .set(field("ts_hash"), tsHash) - .set(field("content_hash"), contentHash) - .execute(); - default -> { - try { - ctx.insertInto(table(name(schema, "documents"))) - .columns(field("doc_id"), field("uri"), field("language"), field("ts_hash"), field("content_hash")) - .values(docId, uri, lang, tsHash, contentHash) - .execute(); - } catch (DataAccessException e) { - ctx.update(table(name(schema, "documents"))) - .set(field("uri"), uri) - .set(field("language"), lang) - .set(field("ts_hash"), tsHash) - .set(field("content_hash"), contentHash) - .where(field("doc_id").eq(docId)) - .execute(); - } + List> records = ctx.select(field(name("uima_type_uri"), String.class), field(name("table_name"), String.class)).from(table(name(schema, "uima_type_registry"))).where(field(name("uima_type_uri")).in(typeNames)).fetch(); + for (Record2 rec : records) { + String uri = rec.value1(); + String tbl = rec.value2(); + if (uri != null && tbl != null) { + typeToTable.put(uri, tbl); } } } private void upsertTypeRegistry(DSLContext ctx, String uimaType, String tableNameHash) { - if (ctx.dialect().family() == SQLDialect.POSTGRES) { - ctx.insertInto(table(name(schema, "uima_type_registry"))) - .columns(field("uima_type_uri"), field("table_name")) - .values(uimaType, tableNameHash) - .onConflict(field("uima_type_uri")).doUpdate() - .set(field("table_name"), tableNameHash) - .execute(); - } else { - try { - ctx.insertInto(table(name(schema, "uima_type_registry"))) - .columns(field("uima_type_uri"), field("table_name")) - .values(uimaType, tableNameHash) - .execute(); - } catch (DataAccessException e) { - ctx.update(table(name(schema, "uima_type_registry"))) - .set(field("table_name"), tableNameHash) - .where(field("uima_type_uri").eq(uimaType)) - .execute(); - } - } + Table tbl = table(name(schema, "uima_type_registry")); + ctx.insertInto(tbl).columns(field(name("uima_type_uri")), field(name("table_name"))).values(uimaType, tableNameHash).onConflict(field(name("uima_type_uri"))).doUpdate().set(field(name("table_name")), tableNameHash).execute(); typeToTable.put(uimaType, tableNameHash); } - private Query insertIgnore(DSLContext ctx, String tableNameHash, Map, Object> values, String colRowHash) { - return switch (ctx.dialect().family()) { - case POSTGRES, SQLITE -> ctx.insertInto(table(name(schema, tableNameHash))) - .set(values) - .onConflict(field(name(colRowHash))).doNothing(); - case MARIADB, MYSQL -> ctx.insertInto(table(name(schema, tableNameHash))) - .set(values) - .onDuplicateKeyIgnore(); - default -> ctx.insertInto(table(name(schema, tableNameHash))).set(values); - }; + private DocumentState getDocumentState(DSLContext ctx, String docId, String tsHash, String contentHash, String pipelineHash) { + var rec = ctx.select(field(name("ts_hash"), String.class), field(name("content_hash"), String.class), field(name("pipeline_hash"), String.class)).from(table(name(schema, "documents"))).where(field(name("doc_id")).eq(docId)).fetchOne(); + if (rec == null) { + return new DocumentState(false, false); + } + boolean upToDate = Objects.equals(tsHash, rec.get(0, String.class)) && Objects.equals(contentHash, rec.get(1, String.class)) && Objects.equals(pipelineHash, rec.get(2, String.class)); + return new DocumentState(true, upToDate); } - private record SofaData(String sofaId, Integer sofaNum, String mime, String uri, String text, String textHash) {} + private void upsertDocument(DSLContext ctx, String docId, String uri, String lang, String tsHash, String contentHash, String pipelineHash) { + Table tbl = table(name(schema, "documents")); + ctx.insertInto(tbl).columns(field(name("doc_id")), field(name("uri")), field(name("language")), field(name("ts_hash")), field(name("content_hash")), field(name("pipeline_hash"))).values(docId, uri, lang, tsHash, contentHash, pipelineHash).onConflict(field(name("doc_id"))).doUpdate().set(field(name("uri")), uri).set(field(name("language")), lang).set(field(name("ts_hash")), tsHash).set(field(name("content_hash")), contentHash).set(field(name("pipeline_hash")), pipelineHash).execute(); + } private Map collectSofas(JCas jCas) { Map result = new TreeMap<>(); org.apache.uima.cas.CAS base = jCas.getCas(); - for (Iterator it = base.getViewIterator(); it.hasNext(); ) { org.apache.uima.cas.CAS view = it.next(); - String sofaId = null; Integer sofaNum = null; - String mime = null, uri = null, text = null; - + String mime = null; + String uri = null; + String text = null; try { var sfs = view.getSofa(); if (sfs != null) { sofaId = emptyToNull(sfs.getSofaID()); mime = sfs.getSofaMime(); uri = sfs.getSofaURI(); - try { sofaNum = sfs.getSofaNum(); } catch (Throwable ignore) {} + try { + sofaNum = sfs.getSofaNum(); + } catch (Throwable ignore) { + } } - } catch (Throwable ignore) {} - + } catch (Throwable ignore) { + } if (sofaId == null) { - try { sofaId = emptyToNull(view.getViewName()); } catch (Throwable ignore) {} + try { + sofaId = emptyToNull(view.getViewName()); + } catch (Throwable ignore) { + } } if (sofaId == null) sofaId = "_InitialView"; - - try { text = view.getDocumentText(); } catch (Throwable ignore) {} + try { + text = view.getDocumentText(); + } catch (Throwable ignore) { + } String textHash = DigestUtils.sha256Hex(text == null ? "" : text); - result.put(sofaId, new SofaData(sofaId, sofaNum, mime, uri, text, textHash)); } return result; } - // Write collected SOFAs to the DB + private SofaData sofaDataForView(Map sofasBySofaId, org.apache.uima.cas.CAS view) { + String sofaId = null; + try { + var sofa = view.getSofa(); + if (sofa != null) sofaId = emptyToNull(sofa.getSofaID()); + } catch (Throwable ignore) { + } + if (sofaId != null && sofasBySofaId.containsKey(sofaId)) { + return sofasBySofaId.get(sofaId); + } + String viewName = null; + try { + viewName = emptyToNull(view.getViewName()); + } catch (Throwable ignore) { + } + if (viewName != null) { + return sofasBySofaId.get(viewName); + } + return sofasBySofaId.get("_InitialView"); + } + private void upsertSofas(DSLContext ctx, String docId, Map sofas) { for (SofaData s : sofas.values()) { upsertSofa(ctx, docId, s.sofaId(), s.sofaNum(), s.mime(), s.uri(), s.text(), s.textHash()); } } - // Convenience: sofa_id -> hash map from collected data (used for content hash) - private Map sofaHashMap(Map sofas) { - Map hashes = new TreeMap<>(); - sofas.forEach((id, s) -> hashes.put(id, s.textHash())); - return hashes; - } - private void upsertSofa(DSLContext ctx, String docId, String sofaId, Integer sofaNum, String mime, String uri, String text, String textHash) { Table tbl = table(name(schema, "sofas")); Field fDoc = field(name("doc_id")); @@ -724,44 +826,21 @@ private void upsertSofa(DSLContext ctx, String docId, String sofaId, Integer sof Field fUri = field(name("sofa_uri")); Field fStr = field(name("sofa_string")); Field fHash = field(name("sofa_hash")); - - if (ctx.dialect().family() == SQLDialect.POSTGRES) { - ctx.insertInto(tbl) - .columns(fDoc, fId, fNum, fMime, fUri, fStr, fHash) - .values(docId, sofaId, sofaNum, mime, uri, text, textHash) - .onConflict(fDoc, fId).doUpdate() - .set(fNum, sofaNum) - .set(fMime, mime) - .set(fUri, uri) - .set(fStr, text) - .set(fHash, textHash) - .execute(); - } else { - try { - ctx.insertInto(tbl) - .columns(fDoc, fId, fNum, fMime, fUri, fStr, fHash) - .values(docId, sofaId, sofaNum, mime, uri, text, textHash) - .execute(); - } catch (DataAccessException e) { - ctx.update(tbl) - .set(fNum, sofaNum) - .set(fMime, mime) - .set(fUri, uri) - .set(fStr, text) - .set(fHash, textHash) - .where(fDoc.eq(docId).and(fId.eq(sofaId))) - .execute(); - } - } + ctx.insertInto(tbl).columns(fDoc, fId, fNum, fMime, fUri, fStr, fHash).values(docId, sofaId, sofaNum, mime, uri, text, textHash).onConflict(fDoc, fId).doUpdate().set(fNum, sofaNum).set(fMime, mime).set(fUri, uri).set(fStr, text).set(fHash, textHash).execute(); } - private String computeContentHashFromSofas(String tsHash, Map sofaHashes) { + private String computeContentHashFromSofas(Map sofas) { MessageDigest md = DigestUtils.getSha256Digest(); - md.update(("ts=" + tsHash + "\n").getBytes(StandardCharsets.UTF_8)); - for (var e : sofaHashes.entrySet()) { - md.update((e.getKey() + "=" + e.getValue() + "\n").getBytes(StandardCharsets.UTF_8)); + for (var e : sofas.entrySet()) { + SofaData s = e.getValue(); + updateHash(md, "sofa_id", s.sofaId()); + updateHash(md, "sofa_num", s.sofaNum() == null ? null : String.valueOf(s.sofaNum())); + updateHash(md, "mime", s.mime()); + updateHash(md, "uri", s.uri()); + updateHash(md, "text_hash", s.textHash()); + md.update((byte) '\n'); } - return DigestUtils.sha256Hex(md.digest()); + return bytesToHex(md.digest()); } private String safeCoveredText(String docText, int docLength, int begin, int end) { @@ -773,8 +852,7 @@ private String safeCoveredText(String docText, int docLength, int begin, int end private String sofaIdForFs(org.apache.uima.cas.FeatureStructure fs) { String id = null; try { - org.apache.uima.cas.SofaFS s = - (fs instanceof AnnotationFS a) ? a.getView().getSofa() : fs.getCAS().getSofa(); + org.apache.uima.cas.SofaFS s = (fs instanceof AnnotationFS a) ? a.getView().getSofa() : fs.getCAS().getSofa(); if (s != null) id = emptyToNull(s.getSofaID()); } catch (Throwable ignore) { } @@ -788,78 +866,139 @@ private String sofaIdForFs(org.apache.uima.cas.FeatureStructure fs) { return id != null ? id : "_InitialView"; } - // Cheaper per-row hash: no covered-text hashing (avoid substring allocations) - private String computeRowHash(TypeSystem ts, Type t, String docId, String tableNameHash, org.apache.uima.cas.FeatureStructure fs) { - MessageDigest md; - try { - md = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - return DigestUtils.sha256Hex(t.getName() + "|" + docId + "|" + tableNameHash); + private TsCache getOrBuildTsCache(TypeSystem ts) { + if (cachedTs == ts && tsCache != null) return tsCache; + Type annoSuper = ts.getType("uima.tcas.Annotation"); + List metas = new ArrayList<>(); + List hashParts = new ArrayList<>(); + hashParts.add("__writer_storeCoveredText=" + storeCoveredText); + for (Iterator it = ts.getTypeIterator(); it.hasNext(); ) { + Type t = it.next(); + if (isSkippableType(t)) continue; + boolean isAnno = annoSuper != null && ts.subsumes(annoSuper, t); + List primFeats = new ArrayList<>(); + List featParts = new ArrayList<>(); + for (Feature f : t.getFeatures()) { + String shortName = f.getShortName(); + if (isAnno && ("begin".equals(shortName) || "end".equals(shortName))) { + continue; + } + String rn = f.getRange().getName(); + if (!UIMA_PRIMITIVE_TO_SQL.containsKey(rn)) continue; + primFeats.add(f); + featParts.add(featSortName(f) + ":" + rn); + } + primFeats.sort(Comparator.comparing(JooqDatabaseWriter::featSortName)); + Collections.sort(featParts); + String tableNameHash = typeToTable.get(t.getName()); + metas.add(new TypeMeta(t, isAnno, primFeats, tableNameHash, storeCoveredText)); + hashParts.add(t.getName() + "|" + (isAnno ? "A" : "F") + "|" + String.join(",", featParts)); } + Collections.sort(hashParts); + String tsHash = DigestUtils.sha256Hex(String.join("\n", hashParts)); + TsCache c = new TsCache(tsHash, metas); + this.cachedTs = ts; + this.tsCache = c; + return c; + } - md.update(("tbl=" + tableNameHash + "|").getBytes(StandardCharsets.UTF_8)); - md.update(("type=" + t.getName() + "|").getBytes(StandardCharsets.UTF_8)); - md.update(("doc=" + (docId == null ? "" : docId) + "|").getBytes(StandardCharsets.UTF_8)); + private boolean isSkippableType(Type t) { + String n = t.getName(); + if (n.startsWith("uima.cas.")) return true; + return n.equals("uima.tcas.Annotation"); + } - try { - String viewName = (fs instanceof AnnotationFS a) ? a.getView().getViewName() : fs.getCAS().getViewName(); - if (viewName != null) md.update(("view=" + viewName + "|").getBytes(StandardCharsets.UTF_8)); - } catch (Exception ignore) { + private String toSafeTableName(String uimaTypeName) { + String sanitized = sanitizeIdent(uimaTypeName).replace("org_texttechnologylab_", "").replace("de_tudarmstadt_ukp_dkpro_core_api_", "").replace("type_", ""); + if (sanitized.length() > Math.max(12, maxIdentifierLength - TABLE_HASH_LEN - 1)) { + sanitized = sanitized.substring(0, Math.max(12, maxIdentifierLength - TABLE_HASH_LEN - 1)); } + String hash = DigestUtils.sha256Hex(uimaTypeName).substring(0, TABLE_HASH_LEN); + return cutWithHash(sanitized + "_" + hash); + } - if (ts.subsumes(ts.getType("uima.tcas.Annotation"), t) && fs instanceof AnnotationFS a) { - md.update(("b=" + a.getBegin() + "|e=" + a.getEnd() + "|").getBytes(StandardCharsets.UTF_8)); - } + private String normalizeSchemaForDialect(String schema, SQLDialect dialect) { + String s = (schema == null || schema.isBlank()) ? "public" : schema; + if (dialect.family() == SQLDialect.H2 && "public".equalsIgnoreCase(s)) return "PUBLIC"; + if (dialect.family() == SQLDialect.POSTGRES) return s.toLowerCase(Locale.ROOT); + return s; + } - List feats = new ArrayList<>(); - for (Feature f : t.getFeatures()) if (isPrimitive(f)) feats.add(f); - feats.sort(Comparator.comparing(f -> { - String s = f.getShortName(); - return s != null ? s : f.getName(); - })); + private String sanitizeIdent(String s) { + return (s == null ? "" : s.replaceAll("[^A-Za-z0-9_]", "_").toLowerCase(Locale.ROOT)); + } - for (Feature f : feats) { - String fname = f.getShortName() != null ? f.getShortName() : f.getName(); - Object v = FeatureJsonSerializer.readPrimitive(fs, f); - if (v == null) continue; - md.update(("f=" + fname + "=" + v + "|").getBytes(StandardCharsets.UTF_8)); - } + private String cutWithHash(String s) { + if (s == null) return ""; + if (s.length() <= maxIdentifierLength) return s; + String hash = DigestUtils.sha256Hex(s).substring(0, 8); + int keep = Math.max(1, maxIdentifierLength - hash.length() - 1); + return s.substring(0, keep) + "_" + hash; + } - return DigestUtils.sha256Hex(md.digest()); + private String sysColName(String tableHash, String base) { + return cutWithHash(tableHash + "_" + sanitizeIdent(base)); + } + + private String featColName(String tableHash, Feature f) { + String base = sanitizeIdent(f.getShortName() != null ? f.getShortName() : f.getName()); + String hash = DigestUtils.sha256Hex(f.getName()).substring(0, 8); + return cutWithHash(tableHash + "_f_" + base + "_" + hash); + } + + private String q(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; } @Override public void destroy() { try { - if (dataSource != null && !dataSource.isClosed()) dataSource.close(); + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + } } catch (Exception ignore) { } super.destroy(); } - static final class FeatureJsonSerializer { + private record RegistryKey(String jdbcUrl, String schema) { + } - static String toJsonPrimitivesOnly(org.apache.uima.cas.FeatureStructure fs) { - Type t = fs.getType(); - StringBuilder sb = new StringBuilder(128); - sb.append("{"); - boolean first = true; + private static final class TypeMeta { + final Type type; + final String typeName; + final boolean isAnno; + final List primFeats; + final String tableNameHash; + final int bindCount; + + TypeMeta(Type type, boolean isAnno, List primFeats, String tableNameHash, boolean storeCoveredText) { + this.type = type; + this.typeName = type.getName(); + this.isAnno = isAnno; + this.primFeats = primFeats; + this.tableNameHash = tableNameHash; + this.bindCount = (isAnno ? (storeCoveredText ? 5 : 4) : 2) + primFeats.size(); + } + } - for (Feature f : t.getFeatures()) { - Object v = readPrimitive(fs, f); - if (v == null) continue; + private static final class TsCache { + final String tsHash; + final List types; - if (!first) sb.append(","); - first = false; + TsCache(String tsHash, List types) { + this.tsHash = tsHash; + this.types = types; + } + } - String k = f.getShortName() != null ? f.getShortName() : f.getName(); - sb.append("\"").append(escape(k)).append("\":").append(primitiveToJson(v)); - } + private record SofaData(String sofaId, Integer sofaNum, String mime, String uri, String text, String textHash) { + } - sb.append("}"); - return sb.toString(); - } + private record DocumentState(boolean exists, boolean upToDate) { + } + static final class FeatureJsonSerializer { static Object readPrimitive(org.apache.uima.cas.FeatureStructure fs, Feature f) { String rn = f.getRange().getName(); return switch (rn) { @@ -874,16 +1013,61 @@ static Object readPrimitive(org.apache.uima.cas.FeatureStructure fs, Feature f) default -> null; }; } + } - private static String primitiveToJson(Object v) { - if (v == null) return "null"; - if (v instanceof Number || v instanceof Boolean) return v.toString(); - return "\"" + escape(v.toString()) + "\""; + private final class CopyBatch { + private final DSLContext tx; + private final String tableName; + private final List columns; + private final StringBuilder buffer = new StringBuilder(1024 * 1024); + private int pending = 0; + + private CopyBatch(DSLContext tx, String tableName, List columns) { + this.tx = tx; + this.tableName = tableName; + this.columns = columns; } - private static String escape(String s) { - if (s == null) return ""; - return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + void add(Object[] row) { + for (int i = 0; i < row.length; i++) { + if (i > 0) buffer.append('\t'); + appendCopyTextValue(buffer, row[i]); + } + buffer.append('\n'); + pending++; + if (pending >= batchSize || buffer.length() >= COPY_FLUSH_CHARS) { + flush(); + } + } + + void flush() { + if (pending == 0) return; + final String data = buffer.toString(); + buffer.setLength(0); + pending = 0; + try { + tx.connection(conn -> { + try { + PGConnection pg = conn.unwrap(PGConnection.class); + CopyManager copyManager = pg.getCopyAPI(); + try (StringReader reader = new StringReader(data)) { + copyManager.copyIn(copySql(), reader); + } + } catch (IOException e) { + throw new SQLException(e); + } + }); + } catch (DataAccessException e) { + throw new DataAccessException("COPY failed for table " + q(schema) + "." + q(tableName) + ": " + rootMsg(e), e); + } + } + + private String copySql() { + StringJoiner joiner = new StringJoiner(", "); + for (String column : columns) { + joiner.add(q(column)); + } + return "COPY " + q(schema) + "." + q(tableName) + " (" + joiner + ")" + " FROM STDIN WITH (FORMAT text, DELIMITER E'\\t', NULL '\\N')"; } } } diff --git a/src/main/java/org/texttechnologylab/udav/importer/JsonDataImporter.java b/src/main/java/org/texttechnologylab/udav/importer/JsonDataImporter.java index 00c377aa..6f39fc65 100644 --- a/src/main/java/org/texttechnologylab/udav/importer/JsonDataImporter.java +++ b/src/main/java/org/texttechnologylab/udav/importer/JsonDataImporter.java @@ -14,10 +14,9 @@ import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; -import org.texttechnologylab.udav.api.service.SourceBuildService; +import org.texttechnologylab.udav.db.SchemaObjectNames; import org.json.XML; @@ -36,28 +35,25 @@ @ConditionalOnProperty(name = "app.json-data-import.enabled", havingValue = "true") public class JsonDataImporter implements ApplicationRunner { - private static final String TABLE = "json_data"; - private static final String COL_NAME = "sourcefile_name"; - private static final String COL_JSON = "json"; + private static final String TABLE = SchemaObjectNames.TABLE_JSON_DATA; + private static final String COL_NAME = SchemaObjectNames.COL_JSON_DATA_SOURCEFILE_NAME; + private static final String COL_JSON = SchemaObjectNames.COL_JSON_DATA_JSON; private static final Logger LOGGER = LoggerFactory.getLogger(JsonDataImporter.class); private final DataSource dataSource; private final Path folder; private final boolean replaceIfDifferent; private final ObjectMapper mapper = new ObjectMapper(); - private final SourceBuildService sourceBuildService; @Value("${app.db.schema:public}") private String schema; public JsonDataImporter( DataSource dataSource, - SourceBuildService sourceBuildService, @Value("${app.json-data-import.folder:sourcefilesJSON}") String folderPath, @Value("${app.json-data-import.replace-if-different:false}") boolean replaceIfDifferent ) { this.dataSource = dataSource; - this.sourceBuildService = sourceBuildService; this.folder = Paths.get(folderPath); this.replaceIfDifferent = replaceIfDifferent; } @@ -168,4 +164,4 @@ private String canonicalize(String json) throws Exception { private String convertXmlToJson(String xml) { return XML.toJSONObject(xml).toString(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/texttechnologylab/udav/importer/MissingSchemaScanner.java b/src/main/java/org/texttechnologylab/udav/importer/MissingSchemaScanner.java index 2f54a953..481b8ac7 100644 --- a/src/main/java/org/texttechnologylab/udav/importer/MissingSchemaScanner.java +++ b/src/main/java/org/texttechnologylab/udav/importer/MissingSchemaScanner.java @@ -7,6 +7,7 @@ import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.stereotype.Component; +import org.texttechnologylab.udav.db.SchemaObjectNames; import org.texttechnologylab.udav.pipeline.PipelineProcessor; import javax.sql.DataSource; @@ -23,14 +24,7 @@ public class MissingSchemaScanner implements ApplicationRunner { @Value("${app.db.schema:public}") private String schema; - @Value("${app.pipeline-table:pipeline}") - private String PIPELINE_TABLE; - - @Value("${app.pipeline-col-id:pipeline_id}") - private String COL_PIPELINE_ID; - - @Value("${app.pipeline-col-json:json}") - private String COL_PIPELINE_JSON; + // Pipeline table/columns are now centralized; keep old @Value overrides out to prevent drift // Optional rate limit / safety @Value("${app.missing-schema.max-per-run:50}") @@ -52,13 +46,13 @@ private void scanAndProcessOnce() throws Exception { DSLContext dsl = DSL.using(c); // Check if pipeline table exists before attempting to query it - if (!tableExists(dsl, schema, PIPELINE_TABLE)) { + if (!tableExists(dsl, schema, SchemaObjectNames.TABLE_PIPELINE)) { return; } // app data table (in your app's schema) - Table P = DSL.table(DSL.name(schema, PIPELINE_TABLE)); - Field P_ID = DSL.field(DSL.name(schema, PIPELINE_TABLE, COL_PIPELINE_ID), String.class); + Table P = DSL.table(DSL.name(schema, SchemaObjectNames.TABLE_PIPELINE)); + Field P_ID = DSL.field(DSL.name(schema, SchemaObjectNames.TABLE_PIPELINE, SchemaObjectNames.COL_PIPELINE_ID), String.class); // catalog view for schemas Table SCHEMATA = DSL.table(DSL.name("information_schema", "schemata")); @@ -111,17 +105,18 @@ private boolean tableExists(DSLContext dsl, String schemaName, String tableName) // --- Simple DB-based advisory lock using a small table --- private boolean tryAcquireLock(DSLContext dsl, String pipelineId) { - Name lockTable = DSL.name(schema, "pipeline_locks"); + Name lockTable = DSL.name(schema, SchemaObjectNames.TABLE_PIPELINE_LOCKS); dsl.createTableIfNotExists(lockTable) - .column("pipeline_id", org.jooq.impl.SQLDataType.VARCHAR(255).nullable(false)) - .column("locked_at", org.jooq.impl.SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(false)) - .constraints(DSL.constraint("PK_pipeline_locks").primaryKey("pipeline_id")) + .column(SchemaObjectNames.COL_PIPELINE_LOCKS_PIPELINE_ID, org.jooq.impl.SQLDataType.VARCHAR(255).nullable(false)) + .column(SchemaObjectNames.COL_PIPELINE_LOCKS_LOCKED_AT, org.jooq.impl.SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(false)) + .constraints(DSL.constraint("PK_" + SchemaObjectNames.TABLE_PIPELINE_LOCKS) + .primaryKey(SchemaObjectNames.COL_PIPELINE_LOCKS_PIPELINE_ID)) .execute(); // try insert; if already exists, we didn’t get the lock try { dsl.insertInto(DSL.table(lockTable)) - .columns(DSL.field("pipeline_id"), DSL.field("locked_at")) + .columns(DSL.field(SchemaObjectNames.COL_PIPELINE_LOCKS_PIPELINE_ID), DSL.field(SchemaObjectNames.COL_PIPELINE_LOCKS_LOCKED_AT)) .values(pipelineId, DSL.currentTimestamp()) .execute(); return true; @@ -132,8 +127,8 @@ private boolean tryAcquireLock(DSLContext dsl, String pipelineId) { } private void releaseLock(DSLContext dsl, String pipelineId) { - dsl.deleteFrom(DSL.table(DSL.name(schema, "pipeline_locks"))) - .where(DSL.field("pipeline_id").eq(pipelineId)) + dsl.deleteFrom(DSL.table(DSL.name(schema, SchemaObjectNames.TABLE_PIPELINE_LOCKS))) + .where(DSL.field(SchemaObjectNames.COL_PIPELINE_LOCKS_PIPELINE_ID).eq(pipelineId)) .execute(); } diff --git a/src/main/java/org/texttechnologylab/udav/importer/PipelineJsonImporter.java b/src/main/java/org/texttechnologylab/udav/importer/PipelineJsonImporter.java index 60e0a228..463886f6 100644 --- a/src/main/java/org/texttechnologylab/udav/importer/PipelineJsonImporter.java +++ b/src/main/java/org/texttechnologylab/udav/importer/PipelineJsonImporter.java @@ -17,6 +17,7 @@ import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.texttechnologylab.udav.api.service.SourceBuildService; +import org.texttechnologylab.udav.db.SchemaObjectNames; import javax.sql.DataSource; import java.nio.charset.StandardCharsets; @@ -35,10 +36,10 @@ @ConditionalOnProperty(name = "app.pipeline-json-import.enabled", havingValue = "true") public class PipelineJsonImporter implements ApplicationRunner { - private static final String TABLE = "pipeline"; - private static final String COL_NAME = "pipeline_name"; - private static final String COL_JSON = "json"; - private static final String PIPELINE_ID = "pipeline_id"; + private static final String TABLE = SchemaObjectNames.TABLE_PIPELINE; + private static final String COL_NAME = SchemaObjectNames.COL_PIPELINE_NAME; + private static final String COL_JSON = SchemaObjectNames.COL_PIPELINE_JSON; + private static final String PIPELINE_ID = SchemaObjectNames.COL_PIPELINE_ID; private static final Logger LOGGER = LoggerFactory.getLogger(PipelineJsonImporter.class); private final DataSource dataSource; private final Path folder; @@ -106,7 +107,7 @@ private void importOne(DSLContext dsl, String canonicalJson = parsed.canonicalJson(); String pipelineIdOriginal = parsed.pipelineId(); - String pipelineName = filenameWithoutExt(p.getFileName().toString()); + String pipelineName = parsed.pipelineName(); if (pipelineNameExists(dsl, T, F_NAME, pipelineName)) { LOGGER.warn("Pipeline with name {} already exists.", pipelineName); @@ -171,11 +172,14 @@ private void importOne(DSLContext dsl, // --- Helpers (unchanged logic, but schema-qualified versions for existence checks) --- private ParsedPipeline parseAndCanonicalize(String raw) throws Exception { - JsonNode root = mapper.readTree(raw); - String pipelineId = extractPipelineId(root); - String canonical = mapper.writeValueAsString(root); - return new ParsedPipeline(canonical, pipelineId); - } + JsonNode root = mapper.readTree(raw); + + String pipelineId = extractPipelineId(root); + String pipelineName = extractPipelineName(root); + + String canonical = mapper.writeValueAsString(root); + return new ParsedPipeline(canonical, pipelineId, pipelineName); +} private String extractPipelineId(JsonNode root) { JsonNode pipelineNode = root; @@ -193,6 +197,25 @@ private String extractPipelineId(JsonNode root) { return idNode.asText(); } + private String extractPipelineName(JsonNode root) { + JsonNode pipelineNode = root; + + if (root.has("pipelines")) { + JsonNode arr = root.get("pipelines"); + if (arr.isArray() && !arr.isEmpty()) { + pipelineNode = arr.get(0); + } + } + + JsonNode nameNode = pipelineNode.get("name"); + + if (nameNode == null || nameNode.isNull() || nameNode.asText().isBlank()) { + return "untitled-pipeline"; + } + + return nameNode.asText(); + } + private boolean pipelineIdExists(DSLContext dsl, Table T, Field F_ID, String pipelineId) { return dsl.fetchExists(selectOne().from(T).where(F_ID.eq(pipelineId))); } @@ -230,16 +253,11 @@ private String ensureUniquePipelineId(DSLContext dsl, Table T, Field 0) ? name.substring(0, dot) : name; - } - private String canonicalize(String json) throws Exception { JsonNode node = mapper.readTree(json); return mapper.writeValueAsString(node); } - private record ParsedPipeline(String canonicalJson, String pipelineId) { + private record ParsedPipeline(String canonicalJson, String pipelineId, String pipelineName) { } } diff --git a/src/main/java/org/texttechnologylab/udav/importer/PostImportIndexBuilder.java b/src/main/java/org/texttechnologylab/udav/importer/PostImportIndexBuilder.java new file mode 100644 index 00000000..42b620fc --- /dev/null +++ b/src/main/java/org/texttechnologylab/udav/importer/PostImportIndexBuilder.java @@ -0,0 +1,167 @@ +package org.texttechnologylab.udav.importer; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import lombok.RequiredArgsConstructor; +import org.apache.commons.codec.digest.DigestUtils; +import org.jooq.DSLContext; +import org.jooq.SQLDialect; +import org.jooq.conf.RenderQuotedNames; +import org.jooq.conf.Settings; +import org.jooq.impl.DSL; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.texttechnologylab.udav.importer.config.DbProps; + +import javax.annotation.PreDestroy; +import java.util.List; +import java.util.Locale; + +import static org.jooq.impl.DSL.*; + +/** + * Builds the per-type secondary indexes (idx_*_doc_sofa[_begin]) once after all COPY + * operations finish. Building btrees in bulk is significantly faster and produces denser + * indexes than maintaining them incrementally during COPY. + * Mirrors JooqDatabaseWriter's identifier scheme so the index names match what the rest + * of the importer expects on the next run (CREATE INDEX IF NOT EXISTS). + */ +@Component +@RequiredArgsConstructor +public class PostImportIndexBuilder { + + private static final Logger LOGGER = LoggerFactory.getLogger(PostImportIndexBuilder.class); + + private final DbProps db; + private HikariDataSource dataSource; + private DSLContext dsl; + + private static boolean tableExists(DSLContext ctx, String schema, String tbl) { + return ctx.fetchExists( + DSL.selectOne().from(table(name("information_schema", "tables"))) + .where(field(name("table_schema"), String.class).eq(schema)) + .and(field(name("table_name"), String.class).eq(tbl))); + } + + private static boolean columnExists(DSLContext ctx, String schema, String tbl, String col) { + return ctx.fetchExists( + DSL.selectOne().from(table(name("information_schema", "columns"))) + .where(field(name("table_schema"), String.class).eq(schema)) + .and(field(name("table_name"), String.class).eq(tbl)) + .and(field(name("column_name"), String.class).eq(col))); + } + + // The two helpers below mirror JooqDatabaseWriter.sysColName / cutWithHash so the names match. + private static String sysColName(String tableHash, String base, int maxIdent) { + String b = base.replaceAll("[^A-Za-z0-9_]", "_").toLowerCase(Locale.ROOT); + return cutWithHash(tableHash + "_" + b, maxIdent); + } + + private static String cutWithHash(String s, int maxIdent) { + if (s.length() <= maxIdent) return s; + String hash = DigestUtils.sha256Hex(s).substring(0, 8); + int keep = Math.max(1, maxIdent - hash.length() - 1); + return s.substring(0, keep) + "_" + hash; + } + + private static String q(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + private static SQLDialect resolveDialect(String explicit, String url) { + if (explicit != null && !explicit.isBlank()) { + try { + return SQLDialect.valueOf(explicit.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignore) { + } + } + String u = url == null ? "" : url.toLowerCase(Locale.ROOT); + if (u.startsWith("jdbc:postgresql:")) return SQLDialect.POSTGRES; + if (u.startsWith("jdbc:h2:")) return SQLDialect.H2; + if (u.startsWith("jdbc:mysql:")) return SQLDialect.MYSQL; + if (u.startsWith("jdbc:mariadb:")) return SQLDialect.MARIADB; + if (u.startsWith("jdbc:sqlite:")) return SQLDialect.SQLITE; + if (u.startsWith("jdbc:duckdb:")) return SQLDialect.DUCKDB; + return SQLDialect.DEFAULT; + } + + private static String normalizeSchemaForDialect(String schema, SQLDialect dialect) { + String s = (schema == null || schema.isBlank()) ? "public" : schema; + if (dialect.family() == SQLDialect.H2 && "public".equalsIgnoreCase(s)) return "PUBLIC"; + if (dialect.family() == SQLDialect.POSTGRES) return s.toLowerCase(Locale.ROOT); + return s; + } + + public void buildIndexes() { + DSLContext ctx = dsl(); + String schema = normalizeSchemaForDialect(db.getSchema(), ctx.dialect()); + int maxIdent = Math.max(16, Math.min(db.getMaxIdent() <= 0 ? 63 : db.getMaxIdent(), 63)); + + List tables = ctx.select(field(name("table_name"), String.class)) + .from(table(name(schema, "uima_type_registry"))) + .fetch(field(name("table_name"), String.class)); + if (tables.isEmpty()) { + LOGGER.info("No registered type tables to index."); + return; + } + + LOGGER.info("Building secondary indexes for {} type tables", tables.size()); + long t0 = System.nanoTime(); + int built = 0, skipped = 0; + for (String tbl : tables) { + if (tbl == null || tbl.isBlank()) continue; + try { + if (!tableExists(ctx, schema, tbl)) { + skipped++; + continue; + } + String colDoc = sysColName(tbl, "doc_id", maxIdent); + String colSofa = sysColName(tbl, "sofa_id", maxIdent); + boolean isAnno = columnExists(ctx, schema, tbl, sysColName(tbl, "fs_begin", maxIdent)); + String idxName; + String createSql; + if (isAnno) { + String colBegin = sysColName(tbl, "fs_begin", maxIdent); + idxName = cutWithHash("idx_" + tbl + "_doc_sofa_begin", maxIdent); + createSql = "CREATE INDEX IF NOT EXISTS " + q(idxName) + " ON " + q(schema) + "." + q(tbl) + + " (" + q(colDoc) + ", " + q(colSofa) + ", " + q(colBegin) + ")"; + } else { + idxName = cutWithHash("idx_" + tbl + "_doc_sofa", maxIdent); + createSql = "CREATE INDEX IF NOT EXISTS " + q(idxName) + " ON " + q(schema) + "." + q(tbl) + + " (" + q(colDoc) + ", " + q(colSofa) + ")"; + } + ctx.execute(createSql); + built++; + } catch (Exception e) { + LOGGER.warn("Failed to build index for table {}: {}", tbl, e.getMessage()); + } + } + LOGGER.info("Built indexes for {} tables (skipped {}) in {} ms", + built, skipped, (System.nanoTime() - t0) / 1_000_000); + } + + private DSLContext dsl() { + if (dsl != null) return dsl; + HikariConfig cfg = new HikariConfig(); + cfg.setJdbcUrl(db.getUrl()); + cfg.setUsername(db.getUser()); + cfg.setPassword(db.getPass()); + cfg.setMaximumPoolSize(2); + cfg.setMinimumIdle(0); + cfg.setAutoCommit(true); + cfg.setPoolName("PostImportIndexBuilderPool"); + this.dataSource = new HikariDataSource(cfg); + SQLDialect dialect = resolveDialect(db.getDialect(), db.getUrl()); + this.dsl = DSL.using(this.dataSource, dialect, + new Settings().withRenderQuotedNames(RenderQuotedNames.ALWAYS)); + return dsl; + } + + @PreDestroy + public void close() { + if (dataSource != null && !dataSource.isClosed()) dataSource.close(); + dataSource = null; + dsl = null; + } +} diff --git a/src/main/java/org/texttechnologylab/udav/importer/RemoveMetaInformation.java b/src/main/java/org/texttechnologylab/udav/importer/RemoveMetaInformation.java index e40fceea..81a30977 100644 --- a/src/main/java/org/texttechnologylab/udav/importer/RemoveMetaInformation.java +++ b/src/main/java/org/texttechnologylab/udav/importer/RemoveMetaInformation.java @@ -7,6 +7,7 @@ import org.apache.uima.jcas.JCas; import org.apache.uima.jcas.cas.TOP; import org.texttechnologylab.annotation.SpacyAnnotatorMetaData; +import org.texttechnologylab.annotation.AnnotatorMetaData; import java.util.HashSet; import java.util.Set; @@ -20,5 +21,9 @@ public void process(JCas jCas) throws AnalysisEngineProcessException { acRemove.forEach(FeatureStructureImplC::removeFromIndexes); + Set acRemove2 = new HashSet<>(JCasUtil.select(jCas, AnnotatorMetaData.class)); + + acRemove2.forEach(FeatureStructureImplC::removeFromIndexes); + } } diff --git a/src/main/java/org/texttechnologylab/udav/pipeline/Pipeline.java b/src/main/java/org/texttechnologylab/udav/pipeline/Pipeline.java index 762b7f17..fa612dfe 100644 --- a/src/main/java/org/texttechnologylab/udav/pipeline/Pipeline.java +++ b/src/main/java/org/texttechnologylab/udav/pipeline/Pipeline.java @@ -8,6 +8,7 @@ import org.jooq.Field; import org.jooq.Table; import org.jooq.impl.DSL; +import org.texttechnologylab.udav.database.DBConstants; import org.texttechnologylab.udav.generators.*; import org.texttechnologylab.udav.generators.common_properties.CommonProperties; import org.texttechnologylab.udav.generators.settings.GeneratorSettings; @@ -39,7 +40,8 @@ private Pipeline(String id, JSONView rootJSONView, HashMap ge this.rootJSONView = rootJSONView; this.generators = generators; this.baseGenerators = baseGenerators; - this.visualizedGenerators = findGeneratorsUsedByVisualizations(); + // Persist/build every declared generator, not only widget-referenced ones. + this.visualizedGenerators = new LinkedHashMap<>(generators); this.dbAccess = dbAccess; currentState = PipelineState.CREATED_GENERATORS; @@ -85,7 +87,7 @@ public static Pipeline fromJSON(String path, DBAccess dbAccess) { } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { - throw new IllegalArgumentException("Invalid pipeline JSON."); + throw new IllegalArgumentException("Invalid pipeline JSON: " + e.getMessage(), e); } } @@ -97,17 +99,27 @@ public static Pipeline fromJSON(String path, DBAccess dbAccess) { * (B) { ...the pipeline... } // single object without the "pipelines" wrapper */ public static Pipeline fromDB(DBAccess dbAccess, String pipelineId) { - if (dbAccess.getDataSource() == null) throw new IllegalArgumentException("dataSource must not be null"); + return fromDB(dbAccess, dbAccess, pipelineId); + } + + /** + * Load a pipeline row from {@code readAccess}'s schema, but build all generators + * (and write their data) using {@code writeAccess}'s schema. + * Use this when the pipeline table lives in a shared schema (app.db.schema) but each + * pipeline's generator data lives in its own schema (the pipeline id). + */ + public static Pipeline fromDB(DBAccess readAccess, DBAccess writeAccess, String pipelineId) { + if (readAccess.getDataSource() == null) throw new IllegalArgumentException("dataSource must not be null"); if (pipelineId == null || pipelineId.isBlank()) throw new IllegalArgumentException("pipelineId must not be null/blank"); final String json; - try (Connection c = dbAccess.getDataSource().getConnection()) { + try (Connection c = readAccess.getDataSource().getConnection()) { DSLContext dsl = DSL.using(c); - // qualify everything with public - Table T = DSL.table(DSL.name("public", "pipeline")); - Field F_JSON = DSL.field(DSL.name("public", "pipeline", "json"), String.class); - Field F_ID = DSL.field(DSL.name("public", "pipeline", "pipeline_id"), String.class); + String pipelineSchema = readAccess.getSchema(); + Table T = DSL.table(DSL.name(pipelineSchema, "pipeline")); + Field F_JSON = DSL.field(DSL.name(pipelineSchema, "pipeline", "json"), String.class); + Field F_ID = DSL.field(DSL.name(pipelineSchema, "pipeline", "pipeline_id"), String.class); String val = dsl.select(F_JSON) .from(T) @@ -128,7 +140,7 @@ public static Pipeline fromDB(DBAccess dbAccess, String pipelineId) { // Accept both a single object or a { "pipelines": [...] } envelope Map root; - Object parsed = mapper.readValue(json, new TypeReference() { + Object parsed = mapper.readValue(json, new TypeReference<>() { }); if (parsed instanceof Map m) { //noinspection unchecked @@ -154,13 +166,13 @@ public static Pipeline fromDB(DBAccess dbAccess, String pipelineId) { throw new IllegalArgumentException("Invalid pipeline JSON: " + append); } - Object first = pipelines.get(0); + Object first = pipelines.getFirst(); if (!(first instanceof Map pipelineMap)) { throw new IllegalArgumentException("Invalid pipeline JSON: pipeline entry is not an object."); } JSONView view = new JSONView(pipelineMap); - Pipeline pipeline = generatePipelineFromJSONView(view, dbAccess); + Pipeline pipeline = generatePipelineFromJSONView(view, writeAccess); // Sanity check: if the DB row was envelope-form with a different id, warn but continue String loadedId = pipeline.getId(); @@ -178,7 +190,9 @@ public static Pipeline fromDB(DBAccess dbAccess, String pipelineId) { public static Pipeline generatePipelineFromJSONView(JSONView pipelineView, DBAccess dbAccess) { try { - pipelineView = new JSONView(mergeGeneratorsIntoSources(pipelineView.asMap())); + Map merged = mergeGeneratorsIntoSources(pipelineView.asMap()); + Map expanded = expandNTemplates(merged, dbAccess); + pipelineView = new JSONView(expanded); String id = getJSONViewString(pipelineView, "id"); JSONView sourcesView = pipelineView.get("sources"); @@ -195,11 +209,21 @@ public static Pipeline generatePipelineFromJSONView(JSONView pipelineView, DBAcc for (JSONView sourcesEntry : sourcesView) { String sourceID = getJSONViewString(sourcesEntry, "id"); String sourceDefinition = getJSONViewOptionalString(sourcesEntry, "uri"); // TODO: Use better key name as this could also be a non uri source - if (sourceDefinition != null && sourceDefinition.contains("@")) continue; // TODO: Remove Source sourceObj = (sourceDefinition == null)? null : decideSourceFromJSONDefinition(sourceDefinition, dbAccess); GeneratorSettings settingsBundle = GeneratorSettings.fromConfig(sourcesEntry); JSONView generatorsView = sourcesEntry.get("createsGenerators"); + boolean requiresSubSources = false; + for (JSONView generatorEntry : generatorsView) { + if (getJSONViewOptionalString(generatorEntry, "__udavSubSourceId") != null) { + requiresSubSources = true; + break; + } + } + if (requiresSubSources && sourceObj != null && !(sourceObj instanceof SourceN) + && isDbJsonBackedSource(stripNSuffix(sourceDefinition))) { + sourceObj = new SourceJsonN(stripNSuffix(sourceDefinition), dbAccess); + } generatorsLoop: for (JSONView generatorEntry : generatorsView) { @@ -228,6 +252,19 @@ public static Pipeline generatePipelineFromJSONView(JSONView pipelineView, DBAcc Generator generator = Generator.constructGenerator(generatorID, generatorType, generatorEntry, sourcesEntry, settingsBundle, dbAccess); + Source generatorSourceObj = sourceObj; + String subSourceId = getJSONViewOptionalString(generatorEntry, "__udavSubSourceId"); + if (subSourceId != null) { + if (!(sourceObj instanceof SourceN sourceN)) { + throw new IllegalArgumentException("Error for generator \"" + generatorID + "\": source does not support grouped expansion."); + } + Source resolvedSubSource = sourceN.getSubSourcesIdToObjectMap().get(subSourceId); + if (resolvedSubSource == null) { + throw new IllegalArgumentException("Error for generator \"" + generatorID + "\": sub-source \"" + subSourceId + "\" not found."); + } + generatorSourceObj = resolvedSubSource; + } + if (extendsGenerators == null) { GeneratorSettings combinedSettings = generator.getSettings(); if (!combinedSettings.getBooleanSettingOrDefault("ignoreCombiCommonProperties", false)) { @@ -243,8 +280,8 @@ public static Pipeline generatePipelineFromJSONView(JSONView pipelineView, DBAcc } if (generator.getSource() == null) { - if (sourceObj == null) throw new IllegalArgumentException("Error for generator \"" + generatorID + "\": Non-derived generators need a source, which is not defined in group with id \"" + sourceID + "\"."); - generator.setSource(sourceObj); + if (generatorSourceObj == null) throw new IllegalArgumentException("Error for generator \"" + generatorID + "\": Non-derived generators need a source, which is not defined in group with id \"" + sourceID + "\"."); + generator.setSource(generatorSourceObj); } generators.put(generatorID, generator); @@ -289,7 +326,7 @@ public static Pipeline generatePipelineFromJSONView(JSONView pipelineView, DBAcc } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { - throw new IllegalArgumentException("Invalid pipeline JSON."); + throw new IllegalArgumentException("Invalid pipeline JSON: " + e.getMessage(), e); } } @@ -305,10 +342,64 @@ public void setupGenerators() throws SQLException { public void saveGeneratorsToDB() throws SQLException { if (currentState != PipelineState.SETUP_GENERATORS) throw new IllegalStateException("Pipeline not in correct state for saving generators to database."); - for (Generator g : visualizedGenerators.values()) g.writeToDB(); + + ensureGeneratorTypeTableExists(); + + for (Generator g : visualizedGenerators.values()) { + try { + g.writeToDB(); + replaceGeneratorTypeRow(g.getId(), g.getClass().getSimpleName()); + } catch (SQLException e) { + throw new SQLException( + "Failed to persist generator \"" + g.getId() + "\" (" + + g.getClass().getName() + ") to database.", + e + ); + } + } + currentState = PipelineState.SAVED_GENERATORS_TO_DB; } + private void ensureGeneratorTypeTableExists() throws SQLException { + final String schema = dbAccess.getSchema(); + try (Connection connection = dbAccess.getDataSource().getConnection()) { + DSLContext dsl = DSL.using(connection); + dsl.createTableIfNotExists(DSL.name(schema, DBConstants.TABLENAME_GENERATORTYPE)) + .column(DBConstants.TABLEATTR_GENERATORID, org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .column(DBConstants.TABLEATTR_GENERATORTYPE, org.jooq.impl.SQLDataType.VARCHAR.length(DBConstants.DEFAULTSIZE_VARCHAR).nullable(false)) + .execute(); + } + } + + private void replaceGeneratorTypeRow(String generatorId, String generatorType) throws SQLException { + final String schema = dbAccess.getSchema(); + + try (Connection connection = dbAccess.getDataSource().getConnection()) { + DSLContext dsl = DSL.using(connection); + + Table table = DSL.table(DSL.name(schema, DBConstants.TABLENAME_GENERATORTYPE)); + Field fieldGeneratorId = DSL.field( + DSL.name(schema, DBConstants.TABLENAME_GENERATORTYPE, DBConstants.TABLEATTR_GENERATORID), + String.class + ); + Field fieldGeneratorType = DSL.field( + DSL.name(schema, DBConstants.TABLENAME_GENERATORTYPE, DBConstants.TABLEATTR_GENERATORTYPE), + String.class + ); + + // Keep one authoritative row per generator id even if the table already contains duplicates. + dsl.deleteFrom(table) + .where(fieldGeneratorId.eq(generatorId)) + .execute(); + + dsl.insertInto(table) + .columns(fieldGeneratorId, fieldGeneratorType) + .values(generatorId, generatorType) + .execute(); + } + } + public void saveToDB() throws SQLException { if (currentState != PipelineState.CREATED_GENERATORS) throw new IllegalStateException("Pipeline not in correct state for saving it to database."); setupGenerators(); @@ -350,12 +441,16 @@ private Map findGeneratorsUsedByVisualizations() { } private static Source decideSourceFromJSONDefinition(String definition, DBAccess dbAccess) throws SQLException, IOException { - if (definition.trim().toUpperCase().endsWith(".JSON")) { - return new SourceJson(definition); - } else if (definition.trim().toUpperCase().endsWith(".JSON@N")) { - return new SourceJsonN(definition); + String normalizedDefinition = stripNSuffix(definition); + if (isDbJsonBackedSource(normalizedDefinition)) { + return new SourceJson(normalizedDefinition, dbAccess); } - return new SourceUIMA(definition, dbAccess); + return new SourceUIMA(normalizedDefinition, dbAccess); + } + + public static String stripNSuffix(String value) { + if (value == null) return null; + return value.trim(); } private static String getJSONViewOptionalString(JSONView view, String name) { @@ -381,18 +476,32 @@ private static Map mergeGeneratorsIntoSources(Map createsGenerators list for quick lookup Map>> sourceGeneratorMap = new LinkedHashMap<>(); + // Track generator IDs already present per source to keep merge idempotent. + Map> sourceGeneratorIds = new LinkedHashMap<>(); for (Map source : originalSources) { Map newSource = new LinkedHashMap<>(source); - String sourceId = (String) source.get("id"); + String sourceId = stripNSuffix((String) source.get("id")); // Ensure createsGenerators exists; copy existing ones if present List> existingGenerators = (List>) source.getOrDefault("createsGenerators", new ArrayList<>()); List> generatorList = new ArrayList<>(existingGenerators); + Set existingIds = new LinkedHashSet<>(); + for (Map existingGenerator : existingGenerators) { + Object existingId = existingGenerator.get("id"); + if (existingId != null) { + String id = existingId.toString().trim(); + if (!id.isEmpty()) { + existingIds.add(id); + } + } + } + newSource.put("createsGenerators", generatorList); sourceGeneratorMap.put(sourceId, generatorList); + sourceGeneratorIds.put(sourceId, existingIds); newSources.add(newSource); } @@ -401,7 +510,7 @@ private static Map mergeGeneratorsIntoSources(Map>) input.getOrDefault("generators", new ArrayList<>()); for (Map generator : standaloneGenerators) { - String sourceId = (String) generator.get("source"); + String sourceId = stripNSuffix((String) generator.get("source")); List> targetList = sourceGeneratorMap.get(sourceId); if (targetList == null) { @@ -409,6 +518,16 @@ private static Map mergeGeneratorsIntoSources(Map seenIds = sourceGeneratorIds.get(sourceId); + if (seenIds.contains(generatorId)) { + continue; + } + seenIds.add(generatorId); + } + // Copy the generator without the "source" key, as it's now implied by nesting Map strippedGenerator = new LinkedHashMap<>(generator); strippedGenerator.remove("source"); @@ -421,4 +540,118 @@ private static Map mergeGeneratorsIntoSources(Map expandNTemplates(Map input, DBAccess dbAccess) throws SQLException, IOException { + Map result = new LinkedHashMap<>(input); + List> originalSources = (List>) input.get("sources"); + if (originalSources == null) { + return result; + } + + List> expandedSources = new ArrayList<>(); + Set globalGeneratorIds = new LinkedHashSet<>(); + + for (Map source : originalSources) { + Map newSource = new LinkedHashMap<>(source); + String sourceDefinition = (String) source.get("uri"); + Source sourceObj = sourceDefinition == null ? null : decideSourceFromJSONDefinition(sourceDefinition, dbAccess); + + List> originalGenerators = + (List>) source.getOrDefault("createsGenerators", new ArrayList<>()); + List> expandedGenerators = new ArrayList<>(); + + for (Map generator : originalGenerators) { + boolean generatorGroup = booleanOrDefault(generator.get("generatorGroup"), false); + + if (!generatorGroup) { + Map copy = new LinkedHashMap<>(generator); + String existingId = stringOrNull(copy.get("id")); + if (existingId != null && !existingId.isBlank()) { + globalGeneratorIds.add(existingId.trim()); + } + expandedGenerators.add(copy); + continue; + } + + SourceN sourceN; + if (sourceObj instanceof SourceN sN) { + sourceN = sN; + } else if (sourceDefinition != null && isDbJsonBackedSource(stripNSuffix(sourceDefinition))) { + sourceObj = new SourceJsonN(stripNSuffix(sourceDefinition), dbAccess); + sourceN = (SourceN) sourceObj; + } else { + String generatorId = stringOrNull(generator.get("id")); + throw new IllegalArgumentException("Generator \"" + generatorId + "\" is grouped but source does not support grouped expansion."); + } + + String normalizedType = stringOrNull(generator.get("type")); + String idTemplate = stringOrNull(generator.get("id")); + + int fallbackIndex = 0; + for (Map.Entry subSourceEntry : sourceN.getSubSourcesIdToObjectMap().entrySet()) { + String subSourceId = subSourceEntry.getKey(); + String resolvedId = resolveExpandedGeneratorId(idTemplate, subSourceId, globalGeneratorIds, fallbackIndex); + while (globalGeneratorIds.contains(resolvedId)) { + fallbackIndex++; + resolvedId = resolveExpandedGeneratorId(idTemplate, Integer.toString(fallbackIndex), globalGeneratorIds, fallbackIndex); + } + globalGeneratorIds.add(resolvedId); + fallbackIndex++; + + Map expandedGenerator = new LinkedHashMap<>(generator); + expandedGenerator.put("type", normalizedType); + expandedGenerator.put("id", resolvedId); + expandedGenerator.remove("source"); + expandedGenerator.put("__udavSubSourceId", subSourceId); + expandedGenerators.add(expandedGenerator); + } + } + + newSource.put("createsGenerators", expandedGenerators); + expandedSources.add(newSource); + } + + result.put("sources", expandedSources); + return result; + } + + private static String resolveExpandedGeneratorId(String idTemplate, String subSourceId, Set usedIds, int fallbackIndex) { + String safeSubSourceId = (subSourceId == null || subSourceId.isBlank()) ? Integer.toString(fallbackIndex) : subSourceId; + String baseId = (idTemplate == null || idTemplate.isBlank()) ? "Generator" : idTemplate.trim(); + String candidate = baseId.contains("@ID@") ? baseId.replace("@ID@", safeSubSourceId) : baseId + "_" + safeSubSourceId; + if (!usedIds.contains(candidate)) { + return candidate; + } + + int suffix = 0; + String withFallback; + do { + withFallback = baseId.contains("@ID@") + ? baseId.replace("@ID@", Integer.toString(suffix)) + : baseId + "_" + suffix; + suffix++; + } while (usedIds.contains(withFallback)); + return withFallback; + } + + private static String stringOrNull(Object value) { + if (value == null) return null; + String stringValue = value.toString().trim(); + return stringValue.isEmpty() ? null : stringValue; + } + + private static boolean booleanOrDefault(Object value, boolean defaultValue) { + if (value == null) return defaultValue; + if (value instanceof Boolean b) return b; + String text = value.toString().trim(); + if (text.isEmpty()) return defaultValue; + return Boolean.parseBoolean(text); + } + + private static boolean isDbJsonBackedSource(String definition) { + if (definition == null) return false; + String normalized = definition.trim().toUpperCase(); + return normalized.endsWith(".JSON") || normalized.endsWith(".XML"); + } } diff --git a/src/main/java/org/texttechnologylab/udav/scheduler/DemoPipelineResetScheduler.java b/src/main/java/org/texttechnologylab/udav/scheduler/DemoPipelineResetScheduler.java new file mode 100644 index 00000000..2ac281f3 --- /dev/null +++ b/src/main/java/org/texttechnologylab/udav/scheduler/DemoPipelineResetScheduler.java @@ -0,0 +1,84 @@ +package org.texttechnologylab.udav.scheduler; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.texttechnologylab.udav.api.service.PipelineService; + +import java.io.InputStream; +import java.util.List; + +@Component +@ConditionalOnProperty(name = "app.pipeline-reset.enabled", havingValue = "true") +public class DemoPipelineResetScheduler { + + private static final Logger LOGGER = LoggerFactory.getLogger(DemoPipelineResetScheduler.class); + + private final PipelineService pipelineService; + private final ObjectMapper objectMapper; + private final ResourceLoader resourceLoader; + + @Value("${app.pipeline-reset.demo-file:pipelines/demo.json}") + private String demoFile; + + public DemoPipelineResetScheduler(PipelineService pipelineService, + ObjectMapper objectMapper, + ResourceLoader resourceLoader) { + this.pipelineService = pipelineService; + this.objectMapper = objectMapper; + this.resourceLoader = resourceLoader; + } + + @Scheduled(cron = "${app.pipeline-reset.cron:0 0 2 * * *}") + public void resetPipelinesDaily() { + try { + List pipelineIds = pipelineService.listAllIds(); + for (String pipelineId : pipelineIds) { + pipelineService.delete(pipelineId); + } + + JsonNode demoPipeline = loadDemoPipeline(); + String recreatedId = pipelineService.create(demoPipeline); + + LOGGER.info("Daily reset completed. Deleted {} pipeline(s), recreated demo pipeline '{}'.", pipelineIds.size(), recreatedId); + } catch (Exception e) { + LOGGER.error("Daily pipeline reset failed: {}", e.getMessage(), e); + } + } + + private JsonNode loadDemoPipeline() throws Exception { + Resource resource = resolveResource(); + if (!resource.exists()) { + throw new IllegalStateException("Demo pipeline resource not found: " + demoFile); + } + + try (InputStream inputStream = resource.getInputStream()) { + JsonNode root = objectMapper.readTree(inputStream); + if (root.has("pipelines")) { + JsonNode pipelines = root.get("pipelines"); + if (!pipelines.isArray() || pipelines.isEmpty()) { + throw new IllegalArgumentException("Demo file has invalid 'pipelines' array: " + demoFile); + } + return pipelines.get(0); + } + if (!root.isObject()) { + throw new IllegalArgumentException("Demo file root must be a JSON object: " + demoFile); + } + return root; + } + } + + private Resource resolveResource() { + if (demoFile.startsWith("classpath:") || demoFile.startsWith("file:")) { + return resourceLoader.getResource(demoFile); + } + return resourceLoader.getResource("classpath:" + demoFile); + } +} diff --git a/src/main/java/org/texttechnologylab/udav/widgets/BoundaryApproximation.java b/src/main/java/org/texttechnologylab/udav/widgets/BoundaryApproximation.java index 4366cfd7..4568bd71 100644 --- a/src/main/java/org/texttechnologylab/udav/widgets/BoundaryApproximation.java +++ b/src/main/java/org/texttechnologylab/udav/widgets/BoundaryApproximation.java @@ -34,27 +34,54 @@ public JsonNode render(String generatorId, return mapper.createArrayNode(); } - Map> result = + Map> pointsByFile = repo.loadMapCoordinatesByFile(schema, generatorId); + Map> edgesByFile = + repo.loadMapCoordinatesEdgesByFile(schema, generatorId); assert mapper != null; - ArrayNode coordinatesArr = mapper.createArrayNode(); + ArrayNode edgePairs = mapper.createArrayNode(); - for (Map.Entry> entry : result.entrySet()) { - List rows = entry.getValue(); - if (rows == null || rows.isEmpty()) continue; + for (Map.Entry> entry : edgesByFile.entrySet()) { + String filename = entry.getKey(); + List fileEdges = entry.getValue(); + List vertices = pointsByFile.get(filename); + if (fileEdges == null || fileEdges.isEmpty() || vertices == null || vertices.isEmpty()) { + continue; + } + + for (GeneratorDataRepository.MapCoordinatesEdgeRow edge : fileEdges) { + int from = edge.fromIndex(); + int to = edge.toIndex(); + if (from < 0 || to < 0 || from >= vertices.size() || to >= vertices.size()) { + continue; + } - for (GeneratorDataRepository.MapCoordinatesRow row : rows) { - if (row.coordinates() != null && row.coordinates().size() > 1) { - ObjectNode coordObj = mapper.createObjectNode(); - coordObj.put("x", row.coordinates().get(0)); - coordObj.put("y", row.coordinates().get(1)); - coordinatesArr.add(coordObj); + GeneratorDataRepository.MapCoordinatesRow fromRow = vertices.get(from); + GeneratorDataRepository.MapCoordinatesRow toRow = vertices.get(to); + if (!isValid2D(fromRow) || !isValid2D(toRow)) { + continue; } + + ArrayNode pair = mapper.createArrayNode(); + pair.add(pointNode(fromRow)); + pair.add(pointNode(toRow)); + edgePairs.add(pair); } } - return coordinatesArr; + return edgePairs; + } + + private boolean isValid2D(GeneratorDataRepository.MapCoordinatesRow row) { + return row != null && row.coordinates() != null && row.coordinates().size() > 1; + } + + private ObjectNode pointNode(GeneratorDataRepository.MapCoordinatesRow row) { + ObjectNode coordObj = mapper.createObjectNode(); + coordObj.put("x", row.coordinates().get(0)); + coordObj.put("y", row.coordinates().get(1)); + return coordObj; } } diff --git a/src/main/java/org/texttechnologylab/udav/widgets/HighlightText.java b/src/main/java/org/texttechnologylab/udav/widgets/HighlightText.java index e23d3ded..749832e8 100644 --- a/src/main/java/org/texttechnologylab/udav/widgets/HighlightText.java +++ b/src/main/java/org/texttechnologylab/udav/widgets/HighlightText.java @@ -17,6 +17,72 @@ public class HighlightText extends Widget { public HighlightText(GeneratorDataRepository repo, ObjectMapper mapper) { super(repo, mapper); } + @Override + public String toCsv(JsonNode jsonNode) { + return toCsvLong(jsonNode); + } + + private String toCsvLong(JsonNode jsonNode) { + JsonNode spans = resolveData(jsonNode).get("spans"); + + StringBuilder csv = new StringBuilder(); + csv.append("text,style,label_index,label,label_style\n"); + + for (JsonNode span : spans) { + String text = span.get("text").asText(); + String style = getOptionalText(span, "style"); + JsonNode labels = span.get("label"); + + if (labels == null || labels.isEmpty()) { + csv.append(escapeCsv(text)).append(',') + .append(escapeCsv(style)).append(",,,\n"); + } else { + int index = 0; + for (JsonNode label : labels) { + String labelText = label.get("text").asText(); + String labelStyle = getOptionalText(label, "style"); + + csv.append(escapeCsv(text)).append(',') + .append(escapeCsv(style)).append(',') + .append(index).append(',') + .append(escapeCsv(labelText)).append(',') + .append(escapeCsv(labelStyle)).append('\n'); + index++; + } + } + } + + return csv.toString(); + } + + // ─────────────────────────────────────────────── + // Helper methods + // ─────────────────────────────────────────────── + + /** Navigate to the "data" node, supporting both root shapes. */ + private JsonNode resolveData(JsonNode jsonNode) { + if (jsonNode.has("data")) { + return jsonNode.get("data"); + } + return jsonNode; + } + + /** Return the text value of a field, or empty string if absent. */ + private String getOptionalText(JsonNode node, String field) { + return node.has(field) ? node.get(field).asText() : ""; + } + + /** Escape a value for CSV: wrap in quotes if it contains comma, quote, or newline. */ + private String escapeCsv(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + if (value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } + /** * Converts the given JsonNode (with a 'data' node containing 'spans') to a LaTeX string. * Supports underlining and highlighting (background color) for each text span. diff --git a/src/main/java/org/texttechnologylab/udav/widgets/NetworkGraph.java b/src/main/java/org/texttechnologylab/udav/widgets/NetworkGraph.java new file mode 100644 index 00000000..9e5f5bec --- /dev/null +++ b/src/main/java/org/texttechnologylab/udav/widgets/NetworkGraph.java @@ -0,0 +1,121 @@ +package org.texttechnologylab.udav.widgets; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Component; +import org.texttechnologylab.udav.api.Repositories.GeneratorDataRepository; +import org.texttechnologylab.udav.api.ValueMode; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component("NetworkGraph") +public class NetworkGraph extends Widget { + + private static final String DEFAULT_NODE_COLOR = "#00618f"; + private static final String DEFAULT_LINK_COLOR = "#9eadbd"; + + public NetworkGraph(GeneratorDataRepository repo, ObjectMapper mapper) { + super(repo, mapper); + } + + @Override + public JsonNode render(String generatorId, + Map filters, + Set files, + ValueMode valueMode, + String schema) { + assert repo != null; + assert mapper != null; + + ObjectNode out = mapper.createObjectNode(); + ArrayNode nodes = mapper.createArrayNode(); + ArrayNode links = mapper.createArrayNode(); + out.set("nodes", nodes); + out.set("links", links); + + if (filters != null && filters.containsKey("hide") + && filters.get("hide") != null + && !filters.get("hide").isEmpty()) { + return out; + } + + Map> pointsByFile = + repo.loadMapCoordinatesByFile(schema, generatorId); + Map> edgesByFile = + repo.loadMapCoordinatesEdgesByFile(schema, generatorId); + + Map nodeIds = new HashMap<>(); + int nextNodeId = 1; + + for (Map.Entry> entry : pointsByFile.entrySet()) { + String filename = entry.getKey(); + if (files != null && !files.isEmpty() && !files.contains(filename)) { + continue; + } + + List rows = entry.getValue(); + if (rows == null || rows.isEmpty()) { + continue; + } + + for (int i = 0; i < rows.size(); i++) { + GeneratorDataRepository.MapCoordinatesRow row = rows.get(i); + int nodeId = nextNodeId++; + nodeIds.put(nodeKey(filename, i), nodeId); + + ObjectNode node = mapper.createObjectNode(); + node.put("id", nodeId); + node.put("name", firstNonBlank(row.label(), filename + "-" + (i + 1))); + node.put("color", firstNonBlank(row.fillColor(), row.strokeColor(), row.outsideColor(), DEFAULT_NODE_COLOR)); + nodes.add(node); + } + } + + for (Map.Entry> entry : edgesByFile.entrySet()) { + String filename = entry.getKey(); + if (files != null && !files.isEmpty() && !files.contains(filename)) { + continue; + } + + List fileEdges = entry.getValue(); + if (fileEdges == null || fileEdges.isEmpty()) { + continue; + } + + for (GeneratorDataRepository.MapCoordinatesEdgeRow edge : fileEdges) { + Integer sourceId = nodeIds.get(nodeKey(filename, edge.fromIndex())); + Integer targetId = nodeIds.get(nodeKey(filename, edge.toIndex())); + if (sourceId == null || targetId == null) { + continue; + } + + ObjectNode link = mapper.createObjectNode(); + link.put("source", sourceId); + link.put("target", targetId); + link.put("color", firstNonBlank(edge.color(), DEFAULT_LINK_COLOR)); + links.add(link); + } + } + + return out; + } + + private static String nodeKey(String filename, int index) { + return filename + "::" + index; + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} + diff --git a/src/main/java/org/texttechnologylab/udav/widgets/ScrollTable.java b/src/main/java/org/texttechnologylab/udav/widgets/ScrollTable.java new file mode 100644 index 00000000..b8d226a4 --- /dev/null +++ b/src/main/java/org/texttechnologylab/udav/widgets/ScrollTable.java @@ -0,0 +1,364 @@ +package org.texttechnologylab.udav.widgets; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Component; +import org.texttechnologylab.udav.api.Repositories.GeneratorDataRepository; +import org.texttechnologylab.udav.api.ValueMode; +import org.texttechnologylab.udav.api.charts.ValueTransforms; +import org.texttechnologylab.udav.widgets.jsontocsv.JsonToCsvConverter; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component("ScrollTable") +public class ScrollTable extends Widget { + public ScrollTable(GeneratorDataRepository repo, ObjectMapper mapper) { super(repo, mapper); } + + public String toTex(JsonNode jsonNode) { + JsonNode dataNode = jsonNode.get("data"); + if (dataNode == null || !dataNode.isArray() || dataNode.isEmpty()) { return null; } + + // New format: each row is an object keyed by column name. + // The first object's values are the display header names; its keys define column order. + JsonNode firstRow = dataNode.get(0); + if (firstRow == null || !firstRow.isObject()) return null; + + List keys = new ArrayList<>(); // field names used as lookup keys + List headers = new ArrayList<>(); // display names (values of the first row) + for (Map.Entry entry : firstRow.properties()) { + keys.add(entry.getKey()); + headers.add(entry.getValue().asText()); + } + + int numColumns = keys.size(); + if (numColumns == 0) return null; + + // Calculate max content length per column for approximate LaTeX column widths + int[] maxLengths = new int[numColumns]; + for (int j = 0; j < numColumns; j++) { + maxLengths[j] = headers.get(j).length(); + } + for (int i = 1; i < dataNode.size(); i++) { + JsonNode row = dataNode.get(i); + for (int j = 0; j < numColumns; j++) { + JsonNode cell = row.get(keys.get(j)); + int len = cell != null ? cell.asText().length() : 0; + if (len > maxLengths[j]) maxLengths[j] = len; + } + } + + StringBuilder sb = new StringBuilder(); + + // LaTeX preamble + sb.append("\\documentclass{standalone}\n") + .append("\\usepackage[utf8]{inputenc}\n") + .append("\\usepackage{array}\n") + .append("\\usepackage{geometry}\n") + .append("\\geometry{margin=1in}\n\n") + .append("\\begin{document}\n\n"); + + // Begin table with dynamic widths + sb.append("\\begin{tabular}{|>{\\centering\\arraybackslash}p{1cm}|"); // # column + for (int j = 0; j < numColumns; j++) { + double width = Math.max(maxLengths[j] * 0.25, 2.0); // rough cm approximation, min 2cm + sb.append(">{\\centering\\arraybackslash}p{").append(String.format("%.2f", width)).append("cm}|"); + } + sb.append("}\n\\hline\n"); + + // Fill table rows — row 0 is the header row, subsequent rows are data + for (int i = 0; i < dataNode.size(); i++) { + JsonNode row = dataNode.get(i); + + // Row number column + sb.append(i == 0 ? "\\textbf{\\#} & " : (i + " & ")); + + for (int j = 0; j < numColumns; j++) { + String rawText = i == 0 + ? headers.get(j) + : (row.get(keys.get(j)) != null ? row.get(keys.get(j)).asText() : ""); + String cellText = escapeLatex(rawText); + + if (i == 0) { + sb.append("\\textbf{").append(cellText).append("}"); + } else { + sb.append(cellText); + } + if (j < numColumns - 1) sb.append(" & "); + } + sb.append(" \\\\\n\\hline\n"); + } + + sb.append("\\end{tabular}\n\n").append("\\end{document}"); + + return sb.toString(); + } + + /** + * Escapes all LaTeX special characters in a plain-text string so it can be safely + * embedded in a LaTeX document without causing syntax errors. + * Order matters: backslash must be replaced first. + */ + private String escapeLatex(String text) { + if (text == null) return ""; + return text + .replace("\\", "\\textbackslash{}") + .replace("&", "\\&") + .replace("%", "\\%") + .replace("$", "\\$") + .replace("#", "\\#") + .replace("_", "\\_") + .replace("{", "\\{") + .replace("}", "\\}") + .replace("~", "\\textasciitilde{}") + .replace("^", "\\textasciicircum{}") + .replace("<", "\\textless{}") + .replace(">", "\\textgreater{}"); + } + + @Override + public JsonNode render(String generatorId, + Map filters, + Set files, + ValueMode valueMode, + String schema) { + + assert mapper != null; + assert repo != null; + ArrayNode out = mapper.createArrayNode(); + String generatorType = resolveGeneratorType(schema, generatorId); + if ("CategoryNumber".equalsIgnoreCase(generatorType)) { + // Optional: chart-specific "type" (e.g., for type-specific colors) + String typeForColors = filters.getOrDefault("type", null); + var data = repo.loadCategoryNumber(schema, generatorId, files, typeForColors); + + // For PER_FILE_AVG only: + Map> perFile = null; + if (valueMode == ValueMode.PER_FILE_AVG) { + perFile = repo.loadCategoryNumberPerFile(schema, generatorId, typeForColors); + } + + Map valuesTx = + ValueTransforms.apply(data.values(), valueMode, perFile, files); + + // build a simple [{label, value, color}] array + out = mapper.createArrayNode(); + for (var entry : valuesTx.entrySet()) { + var obj = mapper.createObjectNode(); + String label = entry.getKey(); + Double value = entry.getValue(); + obj.put("label", label); + obj.put("value", value); + + String color = data.colors().get(label); + if (color != null) { + obj.put("color", color); + } + out.add(obj); + } + } else if ("MapCoordinates".equalsIgnoreCase(generatorType)) { + Map> result = repo.loadMapCoordinatesByFile(schema, generatorId); + out = mapper.createArrayNode(); + for (Map.Entry> entry : result.entrySet()) { + List rows = entry.getValue(); + for (GeneratorDataRepository.MapCoordinatesRow row : rows) { + var obj = mapper.createObjectNode(); + obj.put("label", row.label()); + if (row.coordinates() != null && row.coordinates().size() > 1) { + obj.put("x", row.coordinates().get(0)); + obj.put("y", row.coordinates().get(1)); + } + obj.put("scale", row.scale()); + obj.put("fillColor", row.fillColor()); + obj.put("strokeColor", row.strokeColor()); + obj.put("outsideColor", row.outsideColor()); + out.add(obj); + } + } + } else if ("HighlightText".equalsIgnoreCase(generatorType)) { + + } + + String csv = new JsonToCsvConverter(mapper).convert(out); + + // Convert back to JSON, then apply sort / filter / limit on the table + ArrayNode tableOut = csvToJsonNode(csv); + tableOut = sortFilterLimit(tableOut, filters); + return tableOut; + } + + /** + * Sorts, filters and limits an ArrayNode table produced by csvToJsonNode. + * Row 0 is always the header row and is kept in place. + *
    + *
  • Sorting is numeric when the target column contains numbers, case-insensitive + * alphabetic otherwise.
  • + *
  • min / max filters are applied only to numeric columns.
  • + *
  • limit truncates the data rows (header excluded).
  • + *
+ */ + private ArrayNode sortFilterLimit(ArrayNode data, Map filters) { + if (data == null || data.size() <= 1) return data; + + String sortCol = filters.getOrDefault("sort", "value"); + boolean desc = Boolean.parseBoolean(filters.getOrDefault("desc", "true")); + Double min = parseDoubleOrNull(filters.get("min")); + Double max = parseDoubleOrNull(filters.get("max")); + Integer limit = parseIntOrNull(filters.get("limit")); + + // Separate header (index 0) from data rows + JsonNode header = data.get(0); + List rows = new ArrayList<>(); + for (int i = 1; i < data.size(); i++) rows.add(data.get(i)); + + // Detect whether the sort column holds numeric values + boolean isNumeric = rows.stream() + .map(r -> r.get(sortCol)) + .anyMatch(v -> v != null && v.isNumber()); + + // Apply min / max filter (numeric columns only) + if (isNumeric && (min != null || max != null)) { + double lo = (min == null) ? Double.NEGATIVE_INFINITY : min; + double hi = (max == null) ? Double.POSITIVE_INFINITY : max; + rows = rows.stream() + .filter(r -> { + JsonNode v = r.get(sortCol); + if (v == null || !v.isNumber()) return true; + double d = v.doubleValue(); + return d >= lo && d <= hi; + }) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + } + + // Build comparator — numeric or lexicographic + Comparator cmp; + if (isNumeric) { + cmp = Comparator.comparingDouble(r -> { + JsonNode v = r.get(sortCol); + return (v != null && v.isNumber()) ? v.doubleValue() : 0.0; + }); + } else { + cmp = Comparator.comparing( + (JsonNode r) -> { JsonNode v = r.get(sortCol); return v != null ? v.asText() : ""; }, + String.CASE_INSENSITIVE_ORDER + ); + } + if (desc) cmp = cmp.reversed(); + rows.sort(cmp); + + // Apply limit + if (limit != null && limit >= 0 && limit < rows.size()) { + rows = rows.subList(0, limit); + } + + // Rebuild ArrayNode with header first + ArrayNode result = mapper.createArrayNode(); + result.add(header); + rows.forEach(result::add); + return result; + } + + private static Double parseDoubleOrNull(String s) { + if (s == null || s.isBlank()) return null; + try { return Double.parseDouble(s.trim()); } catch (NumberFormatException e) { return null; } + } + + private static Integer parseIntOrNull(String s) { + if (s == null || s.isBlank()) return null; + try { return Integer.parseInt(s.trim()); } catch (NumberFormatException e) { return null; } + } + + /** + * Converts a CSV string (as produced by JsonToCsvConverter) back into an ArrayNode. + * Every row — including the header — becomes an object keyed by the header column names. + * Values that parse as a double are stored as numbers; everything else is stored as a string. + * + * Example: + * label,value,color -> { "label":"label", "value":"value", "color":"color" } + * NOUN,542.0,#ff0000 -> { "label":"NOUN", "value":542.0, "color":"#ff0000" } + */ + private ArrayNode csvToJsonNode(String csv) { + ArrayNode result = mapper.createArrayNode(); + if (csv == null || csv.isBlank()) return result; + + List> rows = parseCsv(csv); + if (rows.isEmpty()) return result; + + List headers = rows.get(0); + + for (List row : rows) { + ObjectNode obj = mapper.createObjectNode(); + for (int i = 0; i < headers.size(); i++) { + String key = headers.get(i); + String value = i < row.size() ? row.get(i) : ""; + try { + obj.put(key, Double.parseDouble(value)); + } catch (NumberFormatException e) { + obj.put(key, value); + } + } + result.add(obj); + } + + return result; + } + + /** Splits a full CSV string into rows, each row being a list of field values. */ + private List> parseCsv(String csv) { + List> rows = new ArrayList<>(); + if (csv == null || csv.isBlank()) return rows; + + // Character-by-character walk to handle RFC4180 quoted fields that may span lines. + List currentRow = new ArrayList<>(); + StringBuilder field = new StringBuilder(); + boolean inQuotes = false; + + for (int i = 0; i < csv.length(); i++) { + char c = csv.charAt(i); + + if (inQuotes) { + if (c == '"') { + if (i + 1 < csv.length() && csv.charAt(i + 1) == '"') { + field.append('"'); // escaped double-quote inside a quoted field + i++; + } else { + inQuotes = false; // closing quote + } + } else { + field.append(c); + } + } else { + if (c == '"') { + inQuotes = true; + } else if (c == ',') { + currentRow.add(field.toString()); + field.setLength(0); + } else if (c == '\n') { + currentRow.add(field.toString()); + field.setLength(0); + if (!currentRow.isEmpty()) rows.add(currentRow); + currentRow = new ArrayList<>(); + } else if (c == '\r') { + // skip – handled by the \n that follows in \r\n + } else { + field.append(c); + } + } + } + + // Flush trailing field / row that has no terminating newline. + if (field.length() > 0 || !currentRow.isEmpty()) { + currentRow.add(field.toString()); + if (!currentRow.stream().allMatch(String::isBlank)) rows.add(currentRow); + } + + return rows; + } + +} + diff --git a/src/main/java/org/texttechnologylab/udav/widgets/SimpleMap.java b/src/main/java/org/texttechnologylab/udav/widgets/SimpleMap.java new file mode 100644 index 00000000..0b55b8b0 --- /dev/null +++ b/src/main/java/org/texttechnologylab/udav/widgets/SimpleMap.java @@ -0,0 +1,143 @@ +package org.texttechnologylab.udav.widgets; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import org.springframework.stereotype.Component; +import org.texttechnologylab.udav.api.Repositories.GeneratorDataRepository; +import org.texttechnologylab.udav.api.ValueMode; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component("SimpleMap") +public class SimpleMap extends Widget { + + public SimpleMap(GeneratorDataRepository repo, ObjectMapper mapper) { + super(repo, mapper); + } + + @Override + public JsonNode render(String generatorId, + Map filters, + Set files, + ValueMode valueMode, + String schema) { + assert repo != null; + + if (filters != null && filters.containsKey("hide") + && filters.get("hide") != null + && !filters.get("hide").isEmpty()) { + return mapper.createArrayNode(); + } + + Map> result = + repo.loadMapCoordinatesByFile(schema, generatorId); + Map> edgesByFile = + repo.loadMapCoordinatesEdgesByFile(schema, generatorId); + + assert mapper != null; + ArrayNode out = mapper.createArrayNode(); + + for (Map.Entry> entry : result.entrySet()) { + String filename = entry.getKey(); + if (files != null && !files.isEmpty() && !files.contains(filename)) { + continue; + } + + List rows = entry.getValue(); + if (rows == null || rows.isEmpty()) { + continue; + } + + for (GeneratorDataRepository.MapCoordinatesRow row : rows) { + if (row.coordinates() == null || row.coordinates().size() < 2) { + continue; + } + + var feature = mapper.createObjectNode(); + feature.put("type", "Feature"); + + var properties = mapper.createObjectNode(); + properties.put("label", row.label() != null ? row.label() : filename); + + String color = firstNonBlank(row.fillColor(), row.strokeColor(), row.outsideColor(), "#00618f"); + properties.put("color", color); + feature.set("properties", properties); + + var geometry = mapper.createObjectNode(); + geometry.put("type", "Point"); + ArrayNode coordinates = mapper.createArrayNode(); + coordinates.add(row.coordinates().get(0)); + coordinates.add(row.coordinates().get(1)); + geometry.set("coordinates", coordinates); + feature.set("geometry", geometry); + + out.add(feature); + } + + List edges = edgesByFile.get(filename); + if (edges == null || edges.isEmpty()) { + continue; + } + + for (GeneratorDataRepository.MapCoordinatesEdgeRow edge : edges) { + int from = edge.fromIndex(); + int to = edge.toIndex(); + if (from < 0 || to < 0 || from >= rows.size() || to >= rows.size()) { + continue; + } + + GeneratorDataRepository.MapCoordinatesRow fromRow = rows.get(from); + GeneratorDataRepository.MapCoordinatesRow toRow = rows.get(to); + if (fromRow.coordinates() == null || fromRow.coordinates().size() < 2 + || toRow.coordinates() == null || toRow.coordinates().size() < 2) { + continue; + } + + var feature = mapper.createObjectNode(); + feature.put("type", "Feature"); + + var properties = mapper.createObjectNode(); + String lineLabel = firstNonBlank(edge.label(), fromRow.label(), toRow.label(), filename); + properties.put("label", lineLabel); + String lineColor = firstNonBlank(edge.color(), fromRow.strokeColor(), toRow.strokeColor(), "#00618f"); + properties.put("color", lineColor); + if (edge.number() != null) { + properties.put("number", edge.number()); + } + feature.set("properties", properties); + + var geometry = mapper.createObjectNode(); + geometry.put("type", "LineString"); + + ArrayNode lineCoordinates = mapper.createArrayNode(); + ArrayNode fromCoordinates = mapper.createArrayNode(); + fromCoordinates.add(fromRow.coordinates().get(0)); + fromCoordinates.add(fromRow.coordinates().get(1)); + ArrayNode toCoordinates = mapper.createArrayNode(); + toCoordinates.add(toRow.coordinates().get(0)); + toCoordinates.add(toRow.coordinates().get(1)); + + lineCoordinates.add(fromCoordinates); + lineCoordinates.add(toCoordinates); + geometry.set("coordinates", lineCoordinates); + feature.set("geometry", geometry); + + out.add(feature); + } + } + + return out; + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/src/main/java/org/texttechnologylab/udav/widgets/Widget.java b/src/main/java/org/texttechnologylab/udav/widgets/Widget.java index b9f00778..347d00ad 100644 --- a/src/main/java/org/texttechnologylab/udav/widgets/Widget.java +++ b/src/main/java/org/texttechnologylab/udav/widgets/Widget.java @@ -15,9 +15,18 @@ public abstract class Widget implements ChartHandler { protected final GeneratorDataRepository repo; protected final ObjectMapper mapper; + + protected String resolveGeneratorType(String schema, String generatorId) { + if (repo == null) return null; + return repo.loadGeneratorType(schema, generatorId).orElse(null); + } + // Overwrite if diagram should have a custom tex definition public String toTex(JsonNode jsonNode) { return null; } + // Overwrite if diagram should have a custom csv definition + public String toCsv(JsonNode jsonNode) { return null; } + public static Widget constructWidget(String className, GeneratorDataRepository repo, ObjectMapper mapper) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { if (className.contains(".")) { throw new IllegalArgumentException("Class name can't contain \".\"."); diff --git a/src/main/java/org/texttechnologylab/udav/widgets/jsontocsv/JsonToCsvConverter.java b/src/main/java/org/texttechnologylab/udav/widgets/jsontocsv/JsonToCsvConverter.java new file mode 100644 index 00000000..4864334d --- /dev/null +++ b/src/main/java/org/texttechnologylab/udav/widgets/jsontocsv/JsonToCsvConverter.java @@ -0,0 +1,144 @@ +package org.texttechnologylab.udav.widgets.jsontocsv; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +public class JsonToCsvConverter { + + private final ObjectMapper mapper; + private final char delimiter; + + public JsonToCsvConverter() { + this(new ObjectMapper(), ','); + } + + public JsonToCsvConverter(ObjectMapper mapper) { + this(mapper, ','); + } + + public JsonToCsvConverter(ObjectMapper mapper, char delimiter) { + this.mapper = mapper; + this.delimiter = delimiter; + } + + public String convert(String json) throws IOException { + JsonNode root = mapper.readTree(json); + return convert(root); + } + + public String convert(JsonNode root) { + List> rows = toRows(root); + return rowsToCsv(rows); + } + + private List> toRows(JsonNode root) { + List> rows = new ArrayList<>(); + + if (root == null || root.isNull()) { + rows.add(new LinkedHashMap<>()); + return rows; + } + + if (root.isArray()) { + if (root.isEmpty()) { + rows.add(new LinkedHashMap<>()); + return rows; + } + + for (JsonNode item : root) { + LinkedHashMap row = new LinkedHashMap<>(); + flatten(item, "", row); + rows.add(row); + } + return rows; + } + + LinkedHashMap singleRow = new LinkedHashMap<>(); + flatten(root, "", singleRow); + rows.add(singleRow); + return rows; + } + + private void flatten(JsonNode node, String path, Map out) { + if (node == null || node.isNull()) { + if (!path.isEmpty()) out.put(path, ""); + return; + } + + if (node.isObject()) { + for (Map.Entry field : node.properties()) { + String nextPath = path.isEmpty() ? field.getKey() : path + "." + field.getKey(); + flatten(field.getValue(), nextPath, out); + } + return; + } + + if (node.isArray()) { + if (node.isEmpty()) { + if (!path.isEmpty()) out.put(path, "[]"); + return; + } + + for (int i = 0; i < node.size(); i++) { + JsonNode item = node.get(i); + String nextPath = path + "[" + i + "]"; + flatten(item, nextPath, out); + } + return; + } + + if (path.isEmpty()) { + out.put("value", node.asText()); + } else { + out.put(path, node.asText()); + } + } + + private String rowsToCsv(List> rows) { + LinkedHashSet headers = new LinkedHashSet<>(); + for (Map row : rows) { + headers.addAll(row.keySet()); + } + + List columns = new ArrayList<>(headers); + StringBuilder sb = new StringBuilder(); + + if (!columns.isEmpty()) { + for (int i = 0; i < columns.size(); i++) { + if (i > 0) sb.append(delimiter); + sb.append(escape(columns.get(i))); + } + sb.append('\n'); + + for (Map row : rows) { + for (int i = 0; i < columns.size(); i++) { + if (i > 0) sb.append(delimiter); + String value = row.getOrDefault(columns.get(i), ""); + sb.append(escape(value)); + } + sb.append('\n'); + } + } + + return sb.toString(); + } + + private String escape(String value) { + if (value == null) return ""; + + boolean mustQuote = value.indexOf(delimiter) >= 0 + || value.contains("\n") + || value.contains("\r") + || value.contains("\""); + + String escaped = value.replace("\"", "\"\""); + return mustQuote ? "\"" + escaped + "\"" : escaped; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 159198bc..2d2135f1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,8 +2,8 @@ spring.freemarker.template-loader-path=classpath:/templates/ spring.freemarker.suffix=.ftl # LLM API Configuration -app.llm.base-url=${LLM_BASE_URL} -app.llm.api-token=${LLM_API_TOKEN} +app.llm.base-url=${LLM_BASE_URL:} +app.llm.api-token=${LLM_API_TOKEN:} # Application Input Configuration app.input-dir=${APP_INPUT_DIR:src/main/resources/input} # Database Configuration @@ -64,3 +64,7 @@ spring.config.import=optional:file:.env[.properties] management.endpoint.health.probes.enabled=true management.endpoints.web.exposure.include=health,info management.endpoint.health.show-details=never + +app.pipeline-reset.enabled=${PIPELINE_RESET_ENABLED:false} +app.pipeline-reset.cron=${PIPELINE_RESET_CRON:0 0 2 * * *} +app.pipeline-reset.demo-file=${PIPELINE_RESET_DEMO_FILE:pipelines/demo.json} diff --git a/src/main/resources/pipelines/boundary-approximation-new.json b/src/main/resources/pipelines/boundary-approximation-new.json new file mode 100644 index 00000000..a616bbe6 --- /dev/null +++ b/src/main/resources/pipelines/boundary-approximation-new.json @@ -0,0 +1,73 @@ +{ + "id": "dbde7551-943c-409e-a755-kaj9wehfwif", + "name": "boundary-approx-new", + "sources": [ + { + "uri": "BoundaryApproximation.json", + "settings": {}, + "id": "Source-boundary-edges" + } + ], + "generators": [ + { + "name": "Boundary Edge Points", + "type": "MapCoordinates", + "source": "Source-boundary-edges", + "settings": { + "inputFormat": "edgePairs", + "scale": 0.1, + "keysMap": { + "0": { + "x": "from@0", + "y": "from@1" + }, + "1": { + "x": "to@0", + "y": "to@1" + }, + "2": { + "label": "label", + "number": "number", + "color": "color" + } + }, + "fixedKeys": { + "outsideColor": "#ffffff" + } + }, + "extends": [], + "id": "MapCoordinates-boundary-edges" + } + ], + "widgets": [ + { + "type": "BoundaryApproximation", + "title": "Boundary Approximation", + "generator": { + "id": "MapCoordinates-boundary-edges" + }, + "options": { + "interpolate": false, + "clustering": "quadtree" + }, + "w": 8, + "h": 6, + "x": 1, + "y": 1, + "id": "BoundaryApproximation-gc8tns2" + }, + { + "type": "MedialAxis", + "title": "Medial Axis", + "generator": { + "id": "MapCoordinates-boundary-edges" + }, + "options": {}, + "w": 8, + "h": 6, + "x": 9, + "y": 1, + "id": "MedialAxis-9debm9c" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/pipelines/boundary-approximation.json b/src/main/resources/pipelines/boundary-approximation.json new file mode 100644 index 00000000..9aeb8a3a --- /dev/null +++ b/src/main/resources/pipelines/boundary-approximation.json @@ -0,0 +1,196 @@ +{ + "id": "fbd34fa1-ce48-440d-8688-0d7676b78788", + "name": "boundary-approximation", + "sources": [ + { + "uri": "uima.tcas.Annotation", + "settings": {}, + "id": "Source-v7vvw1m" + } + ], + "generators": [ + { + "name": "Boundary Coordinates", + "type": "MapCoordinates", + "source": "Source-v7vvw1m", + "settings": {}, + "extends": [], + "id": "MapCoordinates-kejy8y5" + } + ], + "widgets": [ + { + "type": "StaticText", + "title": "Text", + "src": "Different approaches for boundary approximation", + "options": { + "align": "center", + "size": "5", + "weight": "normal", + "style": "normal", + "decoration": "underline" + }, + "w": 8, + "x": 8, + "y": 0, + "id": "StaticText-gkr5f35" + }, + { + "type": "BoundaryApproximation", + "title": "quadtree clustering", + "generator": { + "id": "MapCoordinates-kejy8y5" + }, + "options": { + "interpolate": false, + "spacing": 5, + "clustering": "quadtree", + "radiusMultiplier": 1, + "gridRows": 10, + "threshold": 15, + "epsilon": 10, + "minPts": 2, + "bandwidth": 20, + "thresholds": 5 + }, + "w": 8, + "h": 6, + "x": 0, + "y": 1, + "id": "BoundaryApproximation-zjexk07" + }, + { + "type": "BoundaryApproximation", + "title": "dbscan clustering", + "generator": { + "id": "MapCoordinates-kejy8y5" + }, + "options": { + "interpolate": false, + "spacing": 5, + "clustering": "dbscan", + "radiusMultiplier": 0.3, + "gridRows": 30, + "threshold": 15, + "epsilon": 10, + "minPts": 2, + "bandwidth": 20, + "thresholds": 5 + }, + "w": 8, + "h": 6, + "x": 8, + "y": 1, + "id": "BoundaryApproximation-saxzu97" + }, + { + "type": "BoundaryApproximation", + "title": "kernel density estimation", + "generator": { + "id": "MapCoordinates-kejy8y5" + }, + "options": { + "interpolate": false, + "spacing": 5, + "clustering": "density", + "radiusMultiplier": 1, + "gridRows": 8, + "threshold": 0, + "epsilon": 10, + "minPts": 2, + "bandwidth": 20, + "thresholds": 5 + }, + "w": 8, + "h": 6, + "x": 16, + "y": 1, + "id": "BoundaryApproximation-a3cgwta" + }, + { + "type": "BoundaryApproximation", + "title": "quadtree clustering + interpolation", + "generator": { + "id": "MapCoordinates-kejy8y5" + }, + "options": { + "interpolate": true, + "spacing": 5, + "clustering": "quadtree", + "radiusMultiplier": 1, + "gridRows": 10, + "threshold": 10, + "epsilon": 10, + "minPts": 2, + "bandwidth": 20, + "thresholds": 5 + }, + "w": 8, + "h": 6, + "x": 0, + "y": 7, + "id": "BoundaryApproximation-hj34xv0" + }, + { + "type": "BoundaryApproximation", + "title": "dbscan clustering + interpolation", + "generator": { + "id": "MapCoordinates-kejy8y5" + }, + "options": { + "interpolate": true, + "spacing": 5, + "clustering": "dbscan", + "radiusMultiplier": 0.1, + "gridRows": 30, + "threshold": 0, + "epsilon": 10, + "minPts": 2, + "bandwidth": 20, + "thresholds": 5 + }, + "w": 8, + "h": 6, + "x": 8, + "y": 7, + "id": "BoundaryApproximation-e66fo5c" + }, + { + "type": "BoundaryApproximation", + "title": "kernel density estimation + interpolation", + "generator": { + "id": "MapCoordinates-kejy8y5" + }, + "options": { + "interpolate": true, + "spacing": 5, + "clustering": "density", + "radiusMultiplier": 1, + "gridRows": 8, + "threshold": 0, + "epsilon": 10, + "minPts": 2, + "bandwidth": 20, + "thresholds": 5 + }, + "w": 8, + "h": 6, + "x": 16, + "y": 7, + "id": "BoundaryApproximation-o8reofu" + }, + { + "type": "MedialAxis", + "title": "Medial Axis", + "generator": { + "id": "MapCoordinates-kejy8y5" + }, + "options": {}, + "w": 8, + "h": 6, + "x": 8, + "y": 13, + "id": "MedialAxis-l3ni8se" + } + ] +} diff --git a/src/main/resources/pipelines/demo.json b/src/main/resources/pipelines/demo.json new file mode 100644 index 00000000..c0cea7bb --- /dev/null +++ b/src/main/resources/pipelines/demo.json @@ -0,0 +1,321 @@ +{ + "id" : "0c1953d4-843b-4de4-a44e-1c607ed5a584", + "name" : "DEMO", + "sources" : [ { + "uri" : "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", + "settings" : { + "sourceFilesWhitelist" : [ ], + "sourceFilesBlacklist" : [ ], + "categoriesWhitelist" : null + }, + "id" : "Source-j6jnkke" + }, { + "uri" : "de.tudarmstadt.ukp.dkpro.core.api.ner.type.NamedEntity", + "settings" : { + "sourceFilesWhitelist" : [ ], + "sourceFilesBlacklist" : [ ] + }, + "id" : "Source-p1uq71i" + }, { + "uri" : "de.tudarmstadt.ukp.dkpro.core.api.ner.type.NamedEntity", + "settings" : { + "sourceFilesWhitelist" : [ ], + "sourceFilesBlacklist" : [ ] + }, + "id" : "Source-rhhz27m" + }, { + "uri" : "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS_NOUN", + "settings" : { + "sourceFilesWhitelist" : [ ], + "sourceFilesBlacklist" : [ ] + }, + "id" : "Source-ovrv9ml" + }, { + "uri" : "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS_DET", + "settings" : { + "sourceFilesWhitelist" : [ ], + "sourceFilesBlacklist" : [ ] + }, + "id" : "Source-pe9viaa" + }, { + "uri" : "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS_ADP", + "settings" : { + "sourceFilesWhitelist" : [ ], + "sourceFilesBlacklist" : [ ] + }, + "id" : "Source-x9crart" + }, { + "uri" : "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS_ADJ", + "settings" : { + "sourceFilesWhitelist" : [ ], + "sourceFilesBlacklist" : [ ] + }, + "id" : "Source-j0x4aue" + }, { + "uri" : "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS_ADV", + "settings" : { + "sourceFilesWhitelist" : [ ], + "sourceFilesBlacklist" : [ ] + }, + "id" : "Source-jh56lsp" + } ], + "generators" : [ { + "name" : "POS_Numbers", + "type" : "CategoryNumber", + "source" : "Source-j6jnkke", + "settings" : { }, + "extends" : [ ], + "id" : "CategoryNumber-eky02g0" + }, { + "name" : "POS_Formatting", + "type" : "TextFormatting", + "source" : "Source-j6jnkke", + "settings" : { + "style" : "underline", + "sofaFile" : "001PL161008Zwischenausschuss161008gesendgKopie" + }, + "extends" : [ ], + "id" : "TextFormatting-35367u8", + "generatorGroup" : null + }, { + "name" : "NE_Numbers", + "type" : "CategoryNumber", + "source" : "Source-p1uq71i", + "settings" : { }, + "extends" : [ ], + "id" : "CategoryNumber-esl7guq" + }, { + "name" : "NE_Formatting", + "type" : "TextFormatting", + "source" : "Source-p1uq71i", + "settings" : { + "style" : "highlight", + "sofaFile" : "001PL161008Zwischenausschuss161008gesendgKopie", + "sofaID" : null + }, + "extends" : [ ], + "id" : "TextFormatting-uytdce8", + "generatorGroup" : null + }, { + "name" : "Total_Formatting", + "type" : "TextFormatting", + "source" : "Source-rhhz27m", + "settings" : { + "style" : "underline", + "sofaFile" : "" + }, + "extends" : [ "TextFormatting-35367u8", "TextFormatting-uytdce8", "TextFormatting-hf4io2z", "TextFormatting-vubumzd", "TextFormatting-fh6ab34", "TextFormatting-lq6c7gt", "TextFormatting-892k706" ], + "id" : "TextFormatting-3qppt1u", + "generatorGroup" : null + }, { + "name" : "POS_NOUN", + "type" : "TextFormatting", + "generatorGroup" : null, + "settings" : { + "style" : "underline", + "sofaFile" : "" + }, + "extends" : [ ], + "id" : "TextFormatting-hf4io2z", + "source" : "Source-ovrv9ml" + }, { + "name" : "POS_DET", + "type" : "TextFormatting", + "generatorGroup" : null, + "settings" : { + "style" : "underline", + "sofaFile" : "" + }, + "extends" : [ ], + "id" : "TextFormatting-vubumzd", + "source" : "Source-pe9viaa" + }, { + "name" : "POS_ADP", + "type" : "TextFormatting", + "generatorGroup" : null, + "settings" : { + "style" : "underline", + "sofaFile" : "" + }, + "extends" : [ ], + "id" : "TextFormatting-fh6ab34", + "source" : "Source-x9crart" + }, { + "name" : "POS_ADJ", + "type" : "TextFormatting", + "generatorGroup" : null, + "settings" : { + "style" : "underline", + "sofaFile" : "" + }, + "extends" : [ ], + "id" : "TextFormatting-lq6c7gt", + "source" : "Source-j0x4aue" + }, { + "name" : "POS_ADV", + "type" : "TextFormatting", + "generatorGroup" : null, + "settings" : { + "style" : "underline", + "sofaFile" : "" + }, + "extends" : [ ], + "id" : "TextFormatting-892k706", + "source" : "Source-jh56lsp" + } ], + "widgets" : [ { + "type" : "StaticText", + "title" : "UDAV", + "src" : "Unified Dynamic Annotation Visualizer", + "options" : { + "align" : "center", + "size" : "1", + "weight" : "bold", + "style" : "normal", + "decoration" : "underline" + }, + "w" : 12, + "x" : 6, + "y" : 0, + "id" : "StaticText-5mptltr", + "h" : 2 + }, { + "type" : "StaticImage", + "title" : "Logo", + "src" : "https://demo.udav.texttechnologylab.org/img/logo.png", + "options" : { }, + "x" : 18, + "y" : 0, + "id" : "StaticImage-2vqblz9" + }, { + "type" : "StaticText", + "title" : "UDAV Introduction", + "src" : "The automatic and manual annotation of unstructured corpora is a routine task in many scientific fields and\nis supported by a variety of existing software solutions. Despite this variety, few solutions currently support\nannotation visualization, especially for dynamic generation and interaction. To bridge this gap and visualize\nannotated corpora based on user-, project-, or corpus-specific aspects, we developed Unified Dynamic Annotation\nVisualizer (UDAV). UDAV is a web-based solution that implements features not supported by comparable\ntools, enabling a customizable and extensible toolbox for interacting with annotations and allowing integration\ninto existing big-data frameworks. We exemplify UDAV through a range of visualizations and also provide an\nevaluation of corpus import and processing performance.", + "options" : { + "align" : "center", + "size" : "5", + "weight" : "normal", + "style" : "normal", + "decoration" : "none" + }, + "w" : 16, + "h" : 4, + "x" : 4, + "y" : 2, + "id" : "StaticText-qavbppr" + }, { + "type" : "StaticText", + "title" : "Parts of Speech and Named Entity", + "src" : "Parts of Speech and Named Entity", + "options" : { + "align" : "center", + "size" : "3", + "weight" : "bold", + "style" : "normal", + "decoration" : "none" + }, + "w" : 8, + "x" : 8, + "y" : 7, + "id" : "StaticText-77mut7s" + }, { + "type" : "BarChart", + "title" : "Parts of Speech (POS)", + "generator" : { + "id" : "CategoryNumber-eky02g0" + }, + "options" : { + "horizontal" : false + }, + "w" : 8, + "h" : 6, + "x" : 0, + "y" : 8, + "id" : "BarChart-cspd570" + }, { + "type" : "HighlightText", + "title" : "POS & NE in one TextFormatting", + "generator" : { + "id" : "TextFormatting-3qppt1u" + }, + "options" : { }, + "w" : 8, + "h" : 14, + "x" : 8, + "y" : 8, + "id" : "HighlightText-dd4noze" + }, { + "type" : "PieChart", + "title" : "Named Entities (NE)", + "generator" : { + "id" : "CategoryNumber-esl7guq" + }, + "options" : { + "hole" : 50, + "legend" : false + }, + "w" : 8, + "h" : 6, + "x" : 16, + "y" : 8, + "id" : "PieChart-aqlay35" + }, { + "type" : "StaticText", + "title" : "Parts of Speech (POS) Caption", + "src" : "The chart above shows the distribution of the different POS categories.", + "options" : { + "align" : "end", + "size" : "6", + "weight" : "normal", + "style" : "italic", + "decoration" : "none" + }, + "w" : 8, + "x" : 0, + "y" : 14, + "id" : "StaticText-2ecv0fi" + }, { + "type" : "StaticText", + "title" : "Named Entities (NE) Caption", + "src" : "The chart above shows the distribution of the different NE categories.", + "options" : { + "align" : "start", + "size" : "6", + "weight" : "normal", + "style" : "italic", + "decoration" : "none" + }, + "w" : 8, + "x" : 16, + "y" : 14, + "id" : "StaticText-i6z3x8e" + }, { + "type" : "ScrollTable", + "title" : "Parts of Speech (POS)", + "generator" : { + "id" : "CategoryNumber-eky02g0" + }, + "options" : { + "numbers" : true + }, + "w" : 8, + "h" : 6, + "x" : 0, + "y" : 16, + "id" : "ScrollTable-jhm05qc" + }, { + "type" : "ScrollTable", + "title" : "Named Entities (NE)", + "generator" : { + "id" : "CategoryNumber-esl7guq" + }, + "options" : { + "numbers" : true + }, + "w" : 8, + "h" : 6, + "x" : 16, + "y" : 16, + "id" : "ScrollTable-wwigmth" + } ] +} diff --git a/src/main/resources/pipelines/medial-axis.json b/src/main/resources/pipelines/medial-axis.json index 6bae548f..833b2cd4 100644 --- a/src/main/resources/pipelines/medial-axis.json +++ b/src/main/resources/pipelines/medial-axis.json @@ -1,51 +1,61 @@ { - "id": "medial-axis", + "id": "9b18903f-5545-48c0-ac1d-6e9672d999f7", + "name": "medial-axis", "sources": [ { - "id": "3", "uri": "XY_dict_single.json", - "createsGenerators": [ - { - "id": "VoronoiMap", - "name": "My Voronoi", - "type": "MapCoordinates", - "settings": { - "keysMap": { - "1_0": { - "X": "coordinates@0", - "Y": "coordinates@1", - "Z_abs": "scale", - "colors_S": [ - "fillColor@Red", - "fillColor@Green", - "fillColor@Blue", - "fillColor@Alpha" - ], - "colors_SE": [ - "strokeColor@Red", - "strokeColor@Green", - "strokeColor@Blue", - "strokeColor@Alpha" - ] - } - }, - "fixedKeys": { "outsideColor": "#ffffff" } + "settings": {}, + "id": "Source-kzf0ool" + } + ], + "generators": [ + { + "name": "My Voronoi", + "type": "MapCoordinates", + "source": "Source-kzf0ool", + "settings": { + "keysMap": { + "1_0": { + "X": "coordinates@0", + "Y": "coordinates@1", + "Z_abs": "scale", + "colors_S": [ + "fillColor@Red", + "fillColor@Green", + "fillColor@Blue", + "fillColor@Alpha" + ], + "colors_SE": [ + "strokeColor@Red", + "strokeColor@Green", + "strokeColor@Blue", + "strokeColor@Alpha" + ] } - } - ] + }, + "fixedKeys": { "outsideColor": "#ffffff" } + }, + "extends": [], + "id": "MapCoordinates-m5lxv08" } ], "widgets": [ { "type": "VoronoiDiagram", "title": "Voronoi Diagram", - "generator": { "id": "VoronoiMap" }, - "options": {}, - "w": 11, + "generator": { + "id": "MapCoordinates-m5lxv08" + }, + "options": { + "min": -1, + "max": 1, + "step": 0.5 + }, + "w": 8, "h": 6, "x": 12, "y": 0, - "id": "VoronoiDiagram-zj7m165" + "id": "VoronoiDiagram-nqmmrx6" }, { "type": "StaticText", @@ -62,29 +72,44 @@ "h": 2, "x": 6, "y": 1, - "id": "StaticText-b7vwdkb" + "id": "StaticText-uatzye1" }, { "type": "MedialAxis", "title": "Medial Axis", - "generator": { "id": "VoronoiMap" }, + "generator": { + "id": "MapCoordinates-m5lxv08" + }, "options": {}, - "w": 11, - "h": 8, - "x": 1, + "w": 10, + "h": 7, + "x": 2, "y": 3, - "id": "MedialAxis-zj7m165" + "id": "MedialAxis-wsgl23h" }, { "type": "BoundaryApproximation", "title": "Boundary Approximation", - "generator": { "id": "VoronoiMap" }, - "options": { "gridRows": 8, "maxRadius": 35, "threshold": 0 }, - "w": 9, + "generator": { + "id": "MapCoordinates-m5lxv08" + }, + "options": { + "interpolate": false, + "spacing": 5, + "clustering": "quadtree", + "radiusMultiplier": 1, + "gridRows": 8, + "threshold": 0, + "epsilon": 10, + "minPts": 2, + "bandwidth": 20, + "thresholds": 5 + }, + "w": 10, "h": 7, "x": 12, "y": 6, - "id": "BoundaryApproximation-dmfnw6o" + "id": "BoundaryApproximation-8hxdcf0" } ] } diff --git a/src/main/resources/pipelines/medialaxis-demo.json b/src/main/resources/pipelines/medialaxis-demo.json index 1b1310d6..25794af9 100644 --- a/src/main/resources/pipelines/medialaxis-demo.json +++ b/src/main/resources/pipelines/medialaxis-demo.json @@ -1,161 +1,118 @@ { - "id": "medialaxis-demo", + "id": "c8e58174-55ae-4f13-a5d4-97efafb76980", + "name": "medialaxis-demo", "sources": [ { - "id": "0", - "uri": "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", + "uri": "points.json", "settings": { "categoriesWhitelist": ["NOUN", "ADV", "PRON", "ADP", "VERB"], "sourceFilesWhitelist": ["ID21200100.xmi"] }, - "createsGenerators": [ - { - "type": "CategoryNumber", - "name": "Gen1", - "id": "POS_Numbers", - "extends": [] - }, - { - "type": "TextFormatting", - "name": "Gen2", - "id": "POS_Formatting", - "extends": [], - "settings": { - "sofaFile": "ID21200100.xmi", - "style": "underline" - } - } - ] - }, + "id": "Source-u21smdu" + } + ], + "generators": [ { - "id": "1", - "uri": "de.tudarmstadt.ukp.dkpro.core.api.ner.type.NamedEntity", + "name": "VoronoiMap", + "type": "MapCoordinates", + "source": "Source-u21smdu", "settings": { - "sourceFilesWhitelist": ["ID21200100.xmi"] - }, - "createsGenerators": [ - { - "type": "CategoryNumber", - "name": "Gen3", - "id": "NE_Numbers", - "extends": [] + "keys": { + "coordinates": ["x", "y"], + "test": "x" }, - { - "type": "TextFormatting", - "name": "Gen4", - "id": "NE_Formatting", - "extends": [], - "settings": { - "sofaFile": "ID21200100", - "sofaID": "_InitialView", - "style": "highlight" - } - } - ] - }, - { - "id": "2", - "uri": "", - "createsGenerators": [ - { - "id": "Total_Formatting", - "name": "My DerivedGenerator", - "type": "TextFormatting", - "extends": ["POS_Formatting", "NE_Formatting"] + "fixedKeys": { + "fillColor": "#000000", + "strokeColor": "#000000", + "outsideColor": "#ffffff", + "scale": 1 } - ] - }, - { - "id": "3", - "uri": "points.json", - "createsGenerators": [ - { - "id": "VoronoiMap", - "name": "My Voronoi", - "type": "MapCoordinates", - "settings": { - "keys": { - "coordinates": ["x", "y"], - "test": "x" - }, - "fixedKeys": { - "fillColor": "#000000", - "strokeColor": "#000000", - "outsideColor": "#ffffff", - "scale": 1 - } - } - } - ] - }, - { - "id": "4", - "uri": "XY_dict_reducedSize.json@N", - "createsGenerators": [ - { - "id": "VoronoiMap_@ID@", - "name": "My Voronoi @ID@", - "type": "MapCoordinates", - "settings": { - "keysMap": { - "1_0": { - "X": "coordinates@0", - "Y": "coordinates@1", - "Z_abs": "scale", - "colors_S": [ - "fillColor@Red", - "fillColor@G", - "fillColor@B", - "fillColor@Alpha" - ], - "colors_SE": [ - "strokeColor@Red", - "strokeColor@G", - "strokeColor@B", - "strokeColor@Alpha" - ] - } - }, - "fixedKeys": { - "outsideColor": "#ffffff" - } - } - } - ] + }, + "extends": [], + "id": "MapCoordinates-107uf35" } ], "widgets": [ { - "id": "VoronoiDiagram-jsdfksj", - "x": 0, - "y": 2, - "w": 12, - "h": 10, - "type": "VoronoiDiagram", - "title": "Voronoi Diagram", + "type": "SimpleMap", + "title": "Simple Map", "generator": { - "id": "VoronoiMap" + "id": "MapCoordinates-107uf35" }, "options": { - "axes": false, - "dots": false - } + "worldColor": "#b8b8b8" + }, + "w": 8, + "h": 6, + "x": 4, + "y": 1, + "id": "SimpleMap-w5oc498" }, { - "id": "VoronoiDiagram-jsdffsdf", - "x": 12, - "y": 2, + "type": "VoronoiDiagram", + "title": "Voronoi Diagram", + "generator": { + "id": "MapCoordinates-107uf35" + }, + "options": { + "min": -1, + "max": 1, + "step": 0.5 + }, "w": 12, "h": 10, + "x": 12, + "y": 2, + "id": "VoronoiDiagram-r1q5pqg" + }, + { + "type": "LineChart", + "title": "Line Chart", + "generator": { + "id": "MapCoordinates-107uf35" + }, + "options": { + "points": true, + "curve": "curveCardinal" + }, + "w": 8, + "h": 6, + "x": 3, + "y": 7, + "id": "LineChart-cp3531k" + }, + { "type": "VoronoiDiagram", "title": "Voronoi Diagram", "generator": { - "id": "VoronoiMap" + "id": "MapCoordinates-107uf35" }, "options": { - "axes": true, - "dots": true - } + "min": -1, + "max": 1, + "step": 0.5 + }, + "w": 12, + "h": 10, + "x": 0, + "y": 13, + "id": "VoronoiDiagram-qu3rpdm" + }, + { + "type": "LineChart", + "title": "Line Chart", + "generator": { + "id": "MapCoordinates-107uf35" + }, + "options": { + "points": true, + "curve": "curveLinear" + }, + "w": 9, + "h": 7, + "x": 14, + "y": 13, + "id": "LineChart-vg4j6b3" } ] } diff --git a/src/main/resources/pipelines/pipelineNewFormatTest.json b/src/main/resources/pipelines/pipelineNewFormatTest.json index 9dbbf784..675634b5 100644 --- a/src/main/resources/pipelines/pipelineNewFormatTest.json +++ b/src/main/resources/pipelines/pipelineNewFormatTest.json @@ -1,91 +1,63 @@ { - "id": "newformattest", + "id": "4ad53eff-70b8-4bd3-adf0-df6ae752f1ed", + "name": "newformattest", "sources": [ { - "id": "0", "uri": "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", - "createsGenerators": [ - { - "type": "CategoryNumber", - "id": "total", - "name": "Gen1", - "token": "CN", - "extends": [], - "settings": { - "categoriesWhitelist": [] - } - }, - { - "type": "TextFormatting", - "id": "text-color", - "name": "Gen2", - "token": "TF", - "extends": [], - "settings": { - "sofaFile": "ID21200100.xmi", - "style": "underline" - } - } - ] + "settings": {}, + "id": "Source-igyg18r" + } + ], + "generators": [ + { + "name": "CategoryNumber", + "type": "CategoryNumber", + "source": "Source-igyg18r", + "settings": {}, + "extends": [], + "id": "CategoryNumber-kyvfyr1" }, { - "id": "1", - "uri": "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", - "createsGenerators": [ - { - "type": "CategoryNumber", - "id": "total", - "name": "Gen3", - "token": "CN", - "extends": [], - "settings": { - "categoriesWhitelist": [] - } - }, - { - "type": "TextFormatting", - "id": "text-color", - "name": "Gen4", - "token": "TF", - "extends": [], - "settings": { - "sofaFile": "ID21200100.xmi", - "style": "underline" - } - } - ] + "name": "TextFormatting", + "type": "TextFormatting", + "source": "Source-igyg18r", + "settings": { + "style": "underline", + "sofaFile": "ID21200100" + }, + "extends": [], + "id": "TextFormatting-mhi6gqc" } ], "widgets": [ { - "id": "text", - "x": 4, - "y": 2, - "w": 8, - "h": 8, "type": "HighlightText", "title": "TextFormatting", "generator": { - "id": "text-color" + "id": "TextFormatting-mhi6gqc" }, "options": {}, - "icon": "bi bi-card-text" - }, - { - "id": "bar-chart2", - "x": 12, - "y": 2, "w": 8, "h": 8, + "x": 4, + "y": 2, + "id": "HighlightText-7g3mrgi" + }, + { "type": "PieChart", "title": "CategoryNumber diagram", "generator": { - "id": "total" + "id": "CategoryNumber-kyvfyr1" }, "options": { - "hole": 50 + "hole": 40, + "legend": true }, - "icon": "bi bi-bar-chart" + "w": 8, + "h": 8, + "x": 12, + "y": 2, + "id": "PieChart-tywwfvq" } ] } diff --git a/src/main/resources/pipelines/pipelineNewFormatTest2.json b/src/main/resources/pipelines/pipelineNewFormatTest2.json index 52bf0ff8..69eedf92 100644 --- a/src/main/resources/pipelines/pipelineNewFormatTest2.json +++ b/src/main/resources/pipelines/pipelineNewFormatTest2.json @@ -1,179 +1,185 @@ { - "id": "newformattest2", + "id": "25ff293e-ae64-44fe-8b66-d64e94a2e440", + "name": "newformattest2", "sources": [ { - "id": "0", "uri": "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", "settings": { "categoriesWhitelist": ["NOUN", "ADV", "PRON", "ADP", "VERB"], "sourceFilesWhitelist": ["ID21200100.xmi"] }, - "createsGenerators": [ - { - "type": "CategoryNumber", - "name": "Gen1", - "id": "POS_Numbers", - "token": "CN", - "settings": {}, - "extends": [] - }, - { - "type": "TextFormatting", - "name": "Gen2", - "id": "POS_Formatting", - "token": "TF", - "extends": [], - "settings": { - "sofaFile": "ID21200100.xmi", - "style": "underline" - } - } - ] + "id": "Source-j6jnkke" }, { - "id": "1", "uri": "de.tudarmstadt.ukp.dkpro.core.api.ner.type.NamedEntity", "settings": { "sourceFilesWhitelist": ["ID21200100.xmi"] }, - "createsGenerators": [ - { - "type": "CategoryNumber", - "name": "Gen3", - "id": "NE_Numbers", - "token": "CN", - "settings": {}, - "extends": [] - }, - { - "type": "TextFormatting", - "name": "Gen4", - "id": "NE_Formatting", - "token": "TF", - "extends": [], - "settings": { - "sofaFile": "ID21200100", - "sofaID": "_InitialView", - "style": "highlight" - } - } - ] + "id": "Source-p1uq71i" }, { - "id": "2", - "uri": "", - "createsGenerators": [ - { - "id": "Total_Formatting", - "name": "My DerivedGenerator", - "type": "TextFormatting", - "token": "TF", - "settings": {}, - "extends": ["POS_Formatting", "NE_Formatting"] - } - ] + "uri": "uima.tcas.Annotation", + "settings": {}, + "id": "Source-rhhz27m" } ], - "widgets": [ + "generators": [ { - "type": "StaticText", - "title": "header", - "src": "Parts of Speech and Named Entity", - "options": { - "style": "text-center fs-2 fw-bold fst-normal text-decoration-underline" + "name": "POS_Numbers", + "type": "CategoryNumber", + "source": "Source-j6jnkke", + "settings": {}, + "extends": [], + "id": "CategoryNumber-eky02g0" + }, + { + "name": "POS_Formatting", + "type": "TextFormatting", + "source": "Source-j6jnkke", + "settings": { + "style": "underline", + "sofaFile": "ID21200100" }, - "icon": "bi bi-fonts", - "w": 8, - "h": 2, - "x": 8, - "y": 0, - "id": "Text-3ujztrd" + "extends": [], + "id": "TextFormatting-35367u8" + }, + { + "name": "NE_Numbers", + "type": "CategoryNumber", + "source": "Source-p1uq71i", + "settings": {}, + "extends": [], + "id": "CategoryNumber-esl7guq" + }, + { + "name": "NE_Formatting", + "type": "TextFormatting", + "source": "Source-p1uq71i", + "settings": { + "style": "highlight", + "sofaID": "_InitialView", + "sofaFile": "ID21200100" + }, + "extends": [], + "id": "TextFormatting-uytdce8" }, + { + "name": "Total_Formatting", + "type": "TextFormatting", + "source": "Source-rhhz27m", + "settings": { + "style": "underline", + "sofaFile": "" + }, + "extends": ["TextFormatting-35367u8", "TextFormatting-uytdce8"], + "id": "TextFormatting-3qppt1u" + } + ], + "widgets": [ { "type": "StaticImage", - "title": "My Logo", - "src": "https://placehold.co/600x400?text=My+Logo", + "title": "Logo", + "src": "https://placehold.co/600x400?text=Logo", "options": {}, - "icon": "bi bi-image", "w": 2, "h": 2, "x": 6, "y": 0, - "id": "Image-zndq9sh" + "id": "StaticImage-2vqblz9" + }, + { + "type": "StaticText", + "title": "Text", + "src": "Parts of Speech and Named Entity", + "options": { + "align": "center", + "size": "2", + "weight": "bold", + "style": "normal", + "decoration": "underline" + }, + "w": 8, + "h": 2, + "x": 8, + "y": 0, + "id": "StaticText-5mptltr" }, { "type": "BarChart", "title": "Parts of Speech (POS)", "generator": { - "id": "POS_Numbers" + "id": "CategoryNumber-eky02g0" }, "options": { "horizontal": false }, - "icon": "bi bi-bar-chart", "w": 8, "h": 6, "x": 0, "y": 4, - "id": "bar-chart" + "id": "BarChart-cspd570" }, { "type": "HighlightText", "title": "POS & NE in one TextFormatting", "generator": { - "id": "Total_Formatting" + "id": "TextFormatting-3qppt1u" }, "options": {}, - "icon": "bi bi-card-text", "w": 8, "h": 8, "x": 8, "y": 4, - "id": "text" + "id": "HighlightText-dd4noze" }, { "type": "BarChart", "title": "Named Entities (NE)", "generator": { - "id": "NE_Numbers" + "id": "CategoryNumber-esl7guq" }, "options": { "horizontal": true }, - "icon": "bi bi-bar-chart", "w": 8, "h": 6, "x": 16, "y": 4, - "id": "bar-chart2" + "id": "BarChart-72rt2z9" }, { "type": "StaticText", "title": "Parts of Speech (POS) Caption", "src": "The chart above shows the distribution of the different POS categories.", "options": { - "style": "text-end fs-6 fw-normal fst-italic text-decoration-none" + "align": "end", + "size": "6", + "weight": "normal", + "style": "italic", + "decoration": "none" }, - "icon": "bi bi-fonts", "w": 8, "h": 2, "x": 0, "y": 10, - "id": "Text-lwq4zbo" + "id": "StaticText-2ecv0fi" }, { "type": "StaticText", "title": "Named Entities (NE) Caption", "src": "The chart above shows the distribution of the different NE categories.", "options": { - "style": "text-start fs-6 fw-normal fst-italic text-decoration-none" + "align": "start", + "size": "6", + "weight": "normal", + "style": "italic", + "decoration": "none" }, - "icon": "bi bi-fonts", "w": 8, "h": 2, "x": 16, "y": 10, - "id": "Text-2smw5fr" + "id": "StaticText-i6z3x8e" } ] } diff --git a/src/main/resources/pipelines/pipelineNewFormatTest3.json b/src/main/resources/pipelines/pipelineNewFormatTest3.json index 972c059d..8f07dd90 100644 --- a/src/main/resources/pipelines/pipelineNewFormatTest3.json +++ b/src/main/resources/pipelines/pipelineNewFormatTest3.json @@ -1,75 +1,39 @@ { - "id": "newformattest3", + "id": "e91b3d0e-f1f6-4688-9dcd-d968304ee163", + "name": "newformattest3", "sources": [ { - "id": "0", "uri": "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", - "createsGenerators": [ - { - "type": "CategoryNumber", - "id": "total", - "name": "Gen1", - "token": "CN", - "extends": [], - "settings": { - "categoriesWhitelist": [] - } - }, - { - "type": "TextFormatting", - "id": "text-color", - "name": "Gen2", - "token": "TF", - "extends": [], - "settings": { - "sofaFile": "ID21200100.xmi", - "style": "underline" - } - } - ] - }, + "settings": {}, + "id": "Source-4gukxhb" + } + ], + "generators": [ { - "id": "1", - "uri": "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", - "createsGenerators": [ - { - "type": "CategoryNumber", - "id": "total", - "name": "Gen3", - "token": "CN", - "extends": [], - "settings": { - "categoriesWhitelist": [] - } - }, - { - "type": "TextFormatting", - "id": "text-color", - "name": "Gen4", - "token": "TF", - "extends": [], - "settings": { - "sofaFile": "ID21200100.xmi", - "style": "underline" - } - } - ] + "name": "TextFormatting Generator", + "type": "TextFormatting", + "source": "Source-4gukxhb", + "settings": { + "style": "underline", + "sofaFile": "ID21200100" + }, + "extends": [], + "id": "TextFormatting-hv9s1kj" } ], "widgets": [ { - "id": "text", - "x": 4, - "y": 2, - "w": 8, - "h": 8, "type": "HighlightText", "title": "TextFormatting", "generator": { - "id": "text-color" + "id": "TextFormatting-hv9s1kj" }, "options": {}, - "icon": "bi bi-card-text" + "w": 10, + "h": 8, + "x": 7, + "y": 2, + "id": "HighlightText-kkeyaq3" } ] } diff --git a/src/main/resources/pipelines/pipelineTableTest.json b/src/main/resources/pipelines/pipelineTableTest.json new file mode 100644 index 00000000..461506ab --- /dev/null +++ b/src/main/resources/pipelines/pipelineTableTest.json @@ -0,0 +1,66 @@ +{ + "id": "f1a99db0-4912-4475-88b4-ff04c326f9ea", + "name": "tabletest1", + "sources": [ + { + "uri": "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", + "settings": {}, + "id": "Source-ck7s0uv" + } + ], + "generators": [ + { + "name": "POS_Numbers", + "type": "CategoryNumber", + "source": "Source-ck7s0uv", + "settings": {}, + "extends": [], + "id": "CategoryNumber-v10bwfu" + } + ], + "widgets": [ + { + "type": "StaticIFrame", + "title": "Inline Frame", + "src": "https://example.com/", + "options": { + "border": true + }, + "w": 8, + "h": 6, + "x": 1, + "y": 2, + "id": "StaticIFrame-nlli5b0" + }, + { + "type": "BarChart", + "title": "POS Chart", + "generator": { + "id": "CategoryNumber-v10bwfu" + }, + "options": { + "horizontal": true + }, + "w": 8, + "h": 6, + "x": 9, + "y": 2, + "id": "BarChart-7fwebxl" + }, + { + "type": "ScrollTable", + "title": "POS Table", + "generator": { + "id": "CategoryNumber-v10bwfu" + }, + "options": { + "numbers": true + }, + "w": 6, + "h": 6, + "x": 17, + "y": 2, + "id": "ScrollTable-y75imz5" + } + ] +} diff --git a/src/main/resources/pipelines/simple-demo.json b/src/main/resources/pipelines/simple-demo.json index ba37fd3e..e495d3cf 100644 --- a/src/main/resources/pipelines/simple-demo.json +++ b/src/main/resources/pipelines/simple-demo.json @@ -1,47 +1,55 @@ { - "id": "simple-demo", + "id": "587bf851-c89d-4bdc-a90d-dd3c1c069edf", + "name": "simple-demo", "sources": [ { - "id": "Source_POS1", "uri": "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", - "createsGenerators": [ - { - "type": "CategoryNumber", - "id": "POS_Numbers", - "name": "POS_Numbers", - "token": "CN", - "extends": [], - "settings": {} - } - ] + "settings": {}, + "id": "Source-mm3zo0t" + } + ], + "generators": [ + { + "name": "POS_Numbers", + "type": "CategoryNumber", + "source": "Source-mm3zo0t", + "settings": {}, + "extends": [], + "id": "CategoryNumber-vjv8atv" } ], "widgets": [ { - "id": "bar-chart", - "x": 8, - "y": 2, - "w": 8, - "h": 6, "type": "BarChart", "title": "POS", - "generator": { "id": "POS_Numbers" }, - "options": { "horizontal": false }, - "icon": "bi bi-bar-chart" + "generator": { + "id": "CategoryNumber-vjv8atv" + }, + "options": { + "horizontal": false + }, + "w": 8, + "h": 6, + "x": 8, + "y": 2, + "id": "BarChart-ltdg7u7" }, { "type": "StaticText", "title": "Bar Chart Caption", - "src": "This bar chart shows the top 10 part of speech tags with their number of occurences.", + "src": "This bar chart shows the top 10 part of speech tags with their number of occurrences.", "options": { - "style": "text-center fs-5 fw-normal fst-normal text-decoration-none" + "align": "center", + "size": "5", + "weight": "normal", + "style": "normal", + "decoration": "none" }, - "icon": "bi bi-type", "w": 8, "h": 2, "x": 8, "y": 8, - "id": "StaticText-dewo6hq" + "id": "StaticText-6sl02qx" } ] } diff --git a/src/main/resources/pipelines/voronoi-demo.json b/src/main/resources/pipelines/voronoi-demo.json index ede2eded..3403707c 100644 --- a/src/main/resources/pipelines/voronoi-demo.json +++ b/src/main/resources/pipelines/voronoi-demo.json @@ -1,173 +1,164 @@ { - "id": "voronoi-demo", + "id": "dbde7551-943c-409e-a755-c62ad387c0f0", + "name": "voronoi-demo", "sources": [ { - "id": "0", - "uri": "de.tudarmstadt.ukp.dkpro.core.api.lexmorph.type.pos.POS", - "settings": { - "categoriesWhitelist": ["NOUN", "ADV", "PRON", "ADP", "VERB"], - "sourceFilesWhitelist": ["ID21200100.xmi"] - }, - "createsGenerators": [ - { - "type": "CategoryNumber", - "name": "Gen1", - "id": "POS_Numbers", - "extends": [] - }, - { - "type": "TextFormatting", - "name": "Gen2", - "id": "POS_Formatting", - "extends": [], - "settings": { - "sofaFile": "ID21200100.xmi", - "style": "underline" - } - } - ] + "uri": "XY_dict_single.json", + "settings": {}, + "id": "Source-gfaki97" }, { - "id": "1", - "uri": "de.tudarmstadt.ukp.dkpro.core.api.ner.type.NamedEntity", - "settings": { - "sourceFilesWhitelist": ["ID21200100.xmi"] - }, - "createsGenerators": [ - { - "type": "CategoryNumber", - "name": "Gen3", - "id": "NE_Numbers", - "extends": [] - }, - { - "type": "TextFormatting", - "name": "Gen4", - "id": "NE_Formatting", - "extends": [], - "settings": { - "sofaFile": "ID21200100", - "sofaID": "_InitialView", - "style": "highlight" - } - } - ] + "uri": "XY_dict_reducedSize.json", + "settings": {}, + "id": "Source-many-voronois" }, { - "id": "2", - "uri": "", - "createsGenerators": [ - { - "id": "Total_Formatting", - "name": "My DerivedGenerator", - "type": "TextFormatting", - "extends": ["POS_Formatting", "NE_Formatting"] + "uri": "BoundaryApproximation.json", + "settings": {}, + "id": "Source-boundary-edges" + } + ], + "generators": [ + { + "name": "My Voronoi", + "type": "MapCoordinates", + "source": "Source-gfaki97", + "settings": { + "keysMap": { + "1_0": { + "X": "coordinates@0", + "Y": "coordinates@1", + "Z_abs": "scale", + "colors_S": [ + "fillColor@Red", + "fillColor@Green", + "fillColor@Blue", + "fillColor@Alpha" + ], + "colors_SE": [ + "strokeColor@Red", + "strokeColor@Green", + "strokeColor@Blue", + "strokeColor@Alpha" + ] + } + }, + "fixedKeys": { + "outsideColor": "#ffffff" } - ] + }, + "extends": [], + "id": "MapCoordinates-1w5ip76" }, { - "id": "3", - "uri": "XY_dict_single.json", - "createsGenerators": [ - { - "id": "VoronoiMap", - "name": "My Voronoi", - "type": "MapCoordinates", - "settings": { - "keysMap": { - "1_0": { - "X": "coordinates@0", - "Y": "coordinates@1", - "Z_abs": "scale", - "colors_S": [ - "fillColor@Red", - "fillColor@Green", - "fillColor@Blue", - "fillColor@Alpha" - ], - "colors_SE": [ - "strokeColor@Red", - "strokeColor@Green", - "strokeColor@Blue", - "strokeColor@Alpha" - ] - } - }, - "fixedKeys": { - "outsideColor": "#ffffff" - } + "name": "My many Voronois", + "type": "MapCoordinates", + "source": "Source-many-voronois", + "generatorGroup": true, + "settings": { + "keysMap": { + "1_0": { + "X": "coordinates@0", + "Y": "coordinates@1", + "Z_abs": "scale", + "colors_S": [ + "fillColor@Red", + "fillColor@Green", + "fillColor@Blue", + "fillColor@Alpha" + ], + "colors_SE": [ + "strokeColor@Red", + "strokeColor@Green", + "strokeColor@Blue", + "strokeColor@Alpha" + ] } + }, + "fixedKeys": { + "outsideColor": "#ffffff" } - ] + }, + "extends": [], + "id": "MapCoordinates-@ID@" }, { - "id": "4", - "uri": "XY_dict_reducedSize.json@N", - "createsGenerators": [ - { - "id": "VoronoiMap_@ID@", - "name": "My Voronoi @ID@", - "type": "MapCoordinates", - "settings": { - "keysMap": { - "1_0": { - "X": "x", - "Y": "y", - "Z_abs": "scale", - "colors_S": [ - "fillColor@Red", - "fillColor@G", - "fillColor@B", - "fillColor@Alpha" - ], - "colors_SE": [ - "strokeColor@Red", - "strokeColor@G", - "strokeColor@B", - "strokeColor@Alpha" - ] - } - }, - "fixedKeys": { - "outsideColor": "#ffffff" - } + "name": "Boundary Edge Points", + "type": "MapCoordinates", + "source": "Source-boundary-edges", + "settings": { + "inputFormat": "edgePairs", + "scale": 0.1, + "keysMap": { + "0": { + "x": "from@0", + "y": "from@1" + }, + "1": { + "x": "to@0", + "y": "to@1" + }, + "2": { + "label": "label", + "number": "number", + "color": "color" } + }, + "fixedKeys": { + "outsideColor": "#ffffff" } - ] + }, + "extends": [], + "id": "MapCoordinates-boundary-edges" } ], "widgets": [ { - "id": "VoronoiDiagram-jsdfksj", - "x": 0, - "y": 2, - "w": 12, - "h": 10, "type": "VoronoiDiagram", "title": "Voronoi Diagram", "generator": { - "id": "VoronoiMap" + "id": "MapCoordinates-@ID@" }, "options": { - "axes": false, - "dots": false - } + "min": -1, + "max": 1, + "step": 0.5 + }, + "w": 11, + "h": 9, + "x": 1, + "y": 2, + "id": "VoronoiDiagram-ek9lf49" }, { - "id": "VoronoiDiagram-jsdffsdf", - "x": 12, - "y": 2, - "w": 12, - "h": 10, "type": "VoronoiDiagram", "title": "Voronoi Diagram", "generator": { - "id": "VoronoiMap" + "id": "MapCoordinates-1w5ip76" }, "options": { - "axes": true, - "dots": true - } + "min": -1, + "max": 1, + "step": 1 + }, + "w": 11, + "h": 9, + "x": 12, + "y": 2, + "id": "VoronoiDiagram-itkn631" + }, + { + "type": "SimpleMap", + "title": "Boundary Edge Map", + "generator": { + "id": "MapCoordinates-boundary-edges" + }, + "options": {}, + "w": 22, + "h": 8, + "x": 1, + "y": 11, + "id": "SimpleMap-boundary-edges" } ] } diff --git a/src/main/resources/sourcefilesJSON/BoundaryApproximation.json b/src/main/resources/sourcefilesJSON/BoundaryApproximation.json new file mode 100644 index 00000000..b6cfe3b8 --- /dev/null +++ b/src/main/resources/sourcefilesJSON/BoundaryApproximation.json @@ -0,0 +1,2012 @@ +[ + [ + { + "x": 276.4694937085933, + "y": 210.96513455773257 + }, + { + "x": 231.44104269965737, + "y": 212.1801872596352 + }, + { + "label": "Edge A1", + "number": 1.0, + "color": "#7f8c8d" + } + ], + [ + { + "x": 279.8532649869678, + "y": 233.57060329542853 + }, + { + "x": 258.5205158524833, + "y": 232.82647918776243 + }, + { + "label": "Edge A2", + "number": 1.1, + "color": "#2980b9" + } + ], + [ + { + "x": 258.5205158524833, + "y": 232.82647918776243 + }, + { + "x": 241.73193780364903, + "y": 233.5652548110095 + }, + { + "label": "Edge A3", + "number": 1.2, + "color": "#27ae60" + } + ], + [ + { + "x": 354.0914634285864, + "y": 159.91399739642264 + }, + { + "x": 344.64352898661446, + "y": 156.26693179627298 + }, + { + "label": "Edge A4", + "number": 0.9, + "color": "#8e44ad" + } + ], + [ + { + "x": 354.0914634285864, + "y": 159.91399739642264 + }, + { + "x": 355.3915585448194, + "y": 160.5893335465575 + }, + { + "label": "Edge A5", + "number": 0.8, + "color": "#d35400" + } + ], + [ + { + "x": 298.0951585828476, + "y": 253.41146322242082 + }, + { + "x": 296.1249795167421, + "y": 253.9237691377335 + }, + { + "label": "Edge A6", + "number": 1.3, + "color": "#c0392b" + } + ], + [ + { + "x": 265.24532194434516, + "y": 280.9354551743008 + }, + { + "x": 262.5295657746439, + "y": 275.52695423311343 + } + ], + [ + { + "x": 265.24532194434516, + "y": 280.9354551743008 + }, + { + "x": 268.52959214005756, + "y": 291.5223584194478 + } + ], + [ + { + "x": 265.24532194434516, + "y": 280.9354551743008 + }, + { + "x": 298.60071254578173, + "y": 288.5246866833821 + } + ], + [ + { + "x": 296.1249795167421, + "y": 253.9237691377335 + }, + { + "x": 251.84475110138965, + "y": 255.84281760512903 + } + ], + [ + { + "x": 306.5044338898832, + "y": 276.4773984066239 + }, + { + "x": 295.4518779399672, + "y": 274.6708656405246 + } + ], + [ + { + "x": 295.4518779399672, + "y": 274.6708656405246 + }, + { + "x": 262.5295657746439, + "y": 275.52695423311343 + } + ], + [ + { + "x": 262.5295657746439, + "y": 275.52695423311343 + }, + { + "x": 257.0016149623103, + "y": 268.3043144390161 + } + ], + [ + { + "x": 257.0016149623103, + "y": 268.3043144390161 + }, + { + "x": 251.84475110138965, + "y": 255.84281760512903 + } + ], + [ + { + "x": 257.0016149623103, + "y": 268.3043144390161 + }, + { + "x": 248.7974160356412, + "y": 280.5476332931568 + } + ], + [ + { + "x": 251.84475110138965, + "y": 255.84281760512903 + }, + { + "x": 250.4395054824601, + "y": 253.70056816871642 + } + ], + [ + { + "x": 355.3915585448194, + "y": 160.5893335465575 + }, + { + "x": 362.7271876763521, + "y": 163.38058961864206 + } + ], + [ + { + "x": 342.1290274358434, + "y": 155.55973640332618 + }, + { + "x": 344.64352898661446, + "y": 156.26693179627298 + } + ], + [ + { + "x": 342.1290274358434, + "y": 155.55973640332618 + }, + { + "x": 326.2469584444125, + "y": 148.66787666927289 + } + ], + [ + { + "x": 326.2469584444125, + "y": 148.66787666927289 + }, + { + "x": 318.71725758629844, + "y": 144.4158418049187 + } + ], + [ + { + "x": 295.0487668801159, + "y": 132.81409188679584 + }, + { + "x": 318.71725758629844, + "y": 144.4158418049187 + } + ], + [ + { + "x": 295.0487668801159, + "y": 132.81409188679584 + }, + { + "x": 270.27799781044007, + "y": 118.42217605259567 + } + ], + [ + { + "x": 362.7271876763521, + "y": 163.38058961864206 + }, + { + "x": 363.4542635879794, + "y": 163.75087253630252 + } + ], + [ + { + "x": 363.4542635879794, + "y": 163.75087253630252 + }, + { + "x": 365.74821640103215, + "y": 165.2516313899089 + } + ], + [ + { + "x": 365.74821640103215, + "y": 165.2516313899089 + }, + { + "x": 370.5318289932407, + "y": 167.27611370781707 + } + ], + [ + { + "x": 370.5318289932407, + "y": 167.27611370781707 + }, + { + "x": 382.695274511083, + "y": 174.39253382785813 + } + ], + [ + { + "x": 382.695274511083, + "y": 174.39253382785813 + }, + { + "x": 383.6933439412148, + "y": 175.17355957148266 + } + ], + [ + { + "x": 383.6933439412148, + "y": 175.17355957148266 + }, + { + "x": 384.31128021867676, + "y": 175.4579812619773 + } + ], + [ + { + "x": 384.31128021867676, + "y": 175.4579812619773 + }, + { + "x": 399.22012219724786, + "y": 184.93445956972047 + } + ], + [ + { + "x": 399.22012219724786, + "y": 184.93445956972047 + }, + { + "x": 399.44337301891267, + "y": 185.13699113589476 + } + ], + [ + { + "x": 399.44337301891267, + "y": 185.13699113589476 + }, + { + "x": 414.9812814645312, + "y": 193.67248550284344 + } + ], + [ + { + "x": 414.9812814645312, + "y": 193.67248550284344 + }, + { + "x": 418.9159865180896, + "y": 194.71940319196102 + } + ], + [ + { + "x": 418.9159865180896, + "y": 194.71940319196102 + }, + { + "x": 428.6565810384989, + "y": 199.92131198286734 + } + ], + [ + { + "x": 303.0569279480815, + "y": 312.93992939747466 + }, + { + "x": 278.90149932292803, + "y": 311.98031759010854 + } + ], + [ + { + "x": 278.90149932292803, + "y": 311.98031759010854 + }, + { + "x": 283.38973920335974, + "y": 323.12917619053445 + } + ], + [ + { + "x": 278.90149932292803, + "y": 311.98031759010854 + }, + { + "x": 277.3417775663491, + "y": 309.5817435279197 + } + ], + [ + { + "x": 428.6565810384989, + "y": 199.92131198286734 + }, + { + "x": 432.7556586672885, + "y": 203.09339467829565 + } + ], + [ + { + "x": 287.11831209783327, + "y": 330.41302506372415 + }, + { + "x": 283.5713242017622, + "y": 323.84335706016253 + } + ], + [ + { + "x": 287.11831209783327, + "y": 330.41302506372415 + }, + { + "x": 287.92291446826823, + "y": 332.6856735638844 + } + ], + [ + { + "x": 432.7556586672885, + "y": 203.09339467829565 + }, + { + "x": 441.3975374800467, + "y": 206.67478407764185 + } + ], + [ + { + "x": 361.3040048990012, + "y": 63.61195904904777 + }, + { + "x": 339.15871092445195, + "y": 66.71700864883806 + } + ], + [ + { + "x": 361.3040048990012, + "y": 63.61195904904777 + }, + { + "x": 364.42494915076975, + "y": 63.82200603457633 + } + ], + [ + { + "x": 287.92291446826823, + "y": 332.6856735638844 + }, + { + "x": 289.1764351271848, + "y": 340.5948470485398 + } + ], + [ + { + "x": 441.3975374800467, + "y": 206.67478407764185 + }, + { + "x": 447.39747291642686, + "y": 210.37431788939247 + } + ], + [ + { + "x": 390.1548582411179, + "y": 67.53150032675359 + }, + { + "x": 391.08696064175916, + "y": 69.8839770573038 + } + ], + [ + { + "x": 390.1548582411179, + "y": 67.53150032675359 + }, + { + "x": 389.37999584841094, + "y": 64.2068812820587 + } + ], + [ + { + "x": 447.39747291642686, + "y": 210.37431788939247 + }, + { + "x": 451.32642608484963, + "y": 211.80833118851157 + } + ], + [ + { + "x": 283.38973920335974, + "y": 323.12917619053445 + }, + { + "x": 283.5713242017622, + "y": 323.84335706016253 + } + ], + [ + { + "x": 277.1977060368536, + "y": 309.16964870547844 + }, + { + "x": 277.3417775663491, + "y": 309.5817435279197 + } + ], + [ + { + "x": 277.1977060368536, + "y": 309.16964870547844 + }, + { + "x": 268.52959214005756, + "y": 291.5223584194478 + } + ], + [ + { + "x": 246.1330571447316, + "y": 245.2122689356378 + }, + { + "x": 241.73193780364903, + "y": 233.5652548110095 + } + ], + [ + { + "x": 246.1330571447316, + "y": 245.2122689356378 + }, + { + "x": 250.4395054824601, + "y": 253.70056816871642 + } + ], + [ + { + "x": 335.99036460271645, + "y": 67.55354638429986 + }, + { + "x": 318.43944775426735, + "y": 75.3669914276231 + } + ], + [ + { + "x": 335.99036460271645, + "y": 67.55354638429986 + }, + { + "x": 339.15871092445195, + "y": 66.71700864883806 + } + ], + [ + { + "x": 241.73193780364903, + "y": 233.5652548110095 + }, + { + "x": 234.5489911050872, + "y": 221.09881210635803 + } + ], + [ + { + "x": 451.32642608484963, + "y": 211.80833118851157 + }, + { + "x": 461.49828856910466, + "y": 217.07679508084726 + } + ], + [ + { + "x": 234.5489911050872, + "y": 221.09881210635803 + }, + { + "x": 234.40093114972322, + "y": 220.77098047332987 + } + ], + [ + { + "x": 234.40093114972322, + "y": 220.77098047332987 + }, + { + "x": 231.44104269965737, + "y": 212.1801872596352 + } + ], + [ + { + "x": 301.5458412055822, + "y": 349.7927024163668 + }, + { + "x": 295.7460170435482, + "y": 350.41479289952423 + } + ], + [ + { + "x": 231.44104269965737, + "y": 212.1801872596352 + }, + { + "x": 229.66326782073838, + "y": 208.48075523428045 + } + ], + [ + { + "x": 463.75836241664285, + "y": 215.92923364434284 + }, + { + "x": 461.49828856910466, + "y": 217.07679508084726 + } + ], + [ + { + "x": 463.75836241664285, + "y": 215.92923364434284 + }, + { + "x": 469.9771368995175, + "y": 213.95285826417518 + } + ], + [ + { + "x": 295.7460170435482, + "y": 350.41479289952423 + }, + { + "x": 299.8402481743836, + "y": 359.78366242908027 + } + ], + [ + { + "x": 295.7460170435482, + "y": 350.41479289952423 + }, + { + "x": 294.00022137633005, + "y": 348.99354222535413 + } + ], + [ + { + "x": 469.9771368995175, + "y": 213.95285826417518 + }, + { + "x": 484.1425740928738, + "y": 211.3218153725464 + } + ], + [ + { + "x": 201.96147559195273, + "y": 224.99610493724492 + }, + { + "x": 215.69938872206262, + "y": 212.1822546999139 + } + ], + [ + { + "x": 215.69938872206262, + "y": 212.1822546999139 + }, + { + "x": 229.66326782073838, + "y": 208.48075523428045 + } + ], + [ + { + "x": 215.69938872206262, + "y": 212.1822546999139 + }, + { + "x": 202.46892694030836, + "y": 213.5047618071658 + } + ], + [ + { + "x": 289.1764351271848, + "y": 340.5948470485398 + }, + { + "x": 294.00022137633005, + "y": 348.99354222535413 + } + ], + [ + { + "x": 461.49828856910466, + "y": 217.07679508084726 + }, + { + "x": 462.30499934136424, + "y": 226.23456844885115 + } + ], + [ + { + "x": 300.70073122305394, + "y": 360.4868882105622 + }, + { + "x": 304.71815897220546, + "y": 368.04666605380226 + } + ], + [ + { + "x": 300.70073122305394, + "y": 360.4868882105622 + }, + { + "x": 299.8402481743836, + "y": 359.78366242908027 + } + ], + [ + { + "x": 229.66326782073838, + "y": 208.48075523428045 + }, + { + "x": 236.3756973040554, + "y": 137.48622582642756 + } + ], + [ + { + "x": 462.30499934136424, + "y": 226.23456844885115 + }, + { + "x": 462.7936573951168, + "y": 227.7606392277182 + } + ], + [ + { + "x": 184.71309518569234, + "y": 158.48500934411254 + }, + { + "x": 227.94895328489568, + "y": 134.3539906238046 + } + ], + [ + { + "x": 395.35004256678917, + "y": 62.80840399415247 + }, + { + "x": 389.37999584841094, + "y": 64.2068812820587 + } + ], + [ + { + "x": 395.35004256678917, + "y": 62.80840399415247 + }, + { + "x": 405.78175099235244, + "y": 61.64085009635142 + } + ], + [ + { + "x": 226.72879272195325, + "y": 133.79183874518966 + }, + { + "x": 218.23704745276075, + "y": 130.03806205298147 + } + ], + [ + { + "x": 226.72879272195325, + "y": 133.79183874518966 + }, + { + "x": 226.95701177950093, + "y": 133.88110873819332 + } + ], + [ + { + "x": 184.55368250522326, + "y": 221.20532621640197 + }, + { + "x": 191.80280014582843, + "y": 217.02499322895773 + } + ], + [ + { + "x": 465.48006967305435, + "y": 240.40216270930256 + }, + { + "x": 462.7936573951168, + "y": 227.7606392277182 + } + ], + [ + { + "x": 465.48006967305435, + "y": 240.40216270930256 + }, + { + "x": 470.2631550740469, + "y": 250.30929773975058 + } + ], + [ + { + "x": 470.2631550740469, + "y": 250.30929773975058 + }, + { + "x": 476.10496248262695, + "y": 257.30649526982756 + } + ], + [ + { + "x": 434.57067170175526, + "y": 84.36421603192149 + }, + { + "x": 426.94369686388944, + "y": 60.8700238480915 + } + ], + [ + { + "x": 364.42494915076975, + "y": 63.82200603457633 + }, + { + "x": 372.42115618470575, + "y": 63.36639521142976 + } + ], + [ + { + "x": 227.94895328489568, + "y": 134.3539906238046 + }, + { + "x": 236.3756973040554, + "y": 137.48622582642756 + } + ], + [ + { + "x": 227.94895328489568, + "y": 134.3539906238046 + }, + { + "x": 226.95701177950093, + "y": 133.88110873819332 + } + ], + [ + { + "x": 202.46892694030836, + "y": 213.5047618071658 + }, + { + "x": 165.30438885078752, + "y": 202.7205426418203 + } + ], + [ + { + "x": 202.46892694030836, + "y": 213.5047618071658 + }, + { + "x": 191.80280014582843, + "y": 217.02499322895773 + } + ], + [ + { + "x": 191.80280014582843, + "y": 217.02499322895773 + }, + { + "x": 187.82968299220013, + "y": 217.23167084533074 + } + ], + [ + { + "x": 218.23704745276075, + "y": 130.03806205298147 + }, + { + "x": 195.55832773322336, + "y": 118.6649859817357 + } + ], + [ + { + "x": 476.10496248262695, + "y": 257.30649526982756 + }, + { + "x": 478.2777113172204, + "y": 260.89941706375345 + } + ], + [ + { + "x": 385.68987687102435, + "y": 63.86911575158182 + }, + { + "x": 389.37999584841094, + "y": 64.2068812820587 + } + ], + [ + { + "x": 385.68987687102435, + "y": 63.86911575158182 + }, + { + "x": 383.42279908412814, + "y": 63.99829171383077 + } + ], + [ + { + "x": 236.3756973040554, + "y": 137.48622582642756 + }, + { + "x": 248.0767215873927, + "y": 129.51210230488735 + } + ], + [ + { + "x": 478.2777113172204, + "y": 260.89941706375345 + }, + { + "x": 478.1752152705529, + "y": 263.64721959428994 + } + ], + [ + { + "x": 478.2777113172204, + "y": 260.89941706375345 + }, + { + "x": 484.14454857266827, + "y": 260.7760729964323 + } + ], + [ + { + "x": 484.1425740928738, + "y": 211.3218153725464 + }, + { + "x": 491.5850548199624, + "y": 211.6154262352176 + } + ], + [ + { + "x": 270.27799781044007, + "y": 118.42217605259567 + }, + { + "x": 258.9287117144893, + "y": 123.31841182717427 + } + ], + [ + { + "x": 270.27799781044007, + "y": 118.42217605259567 + }, + { + "x": 273.3901450234493, + "y": 113.54181379158128 + } + ], + [ + { + "x": 491.5850548199624, + "y": 211.6154262352176 + }, + { + "x": 505.2025327843412, + "y": 214.81294278313706 + } + ], + [ + { + "x": 484.14454857266827, + "y": 260.7760729964323 + }, + { + "x": 490.67962875529753, + "y": 262.40139958947236 + } + ], + [ + { + "x": 372.42115618470575, + "y": 63.36639521142976 + }, + { + "x": 383.42279908412814, + "y": 63.99829171383077 + } + ], + [ + { + "x": 318.43944775426735, + "y": 75.3669914276231 + }, + { + "x": 315.4090260484865, + "y": 76.56369384292763 + } + ], + [ + { + "x": 309.6368036127734, + "y": 79.5906068574464 + }, + { + "x": 298.8971141987269, + "y": 86.95396607065996 + } + ], + [ + { + "x": 309.6368036127734, + "y": 79.5906068574464 + }, + { + "x": 315.4090260484865, + "y": 76.56369384292763 + } + ], + [ + { + "x": 298.8971141987269, + "y": 86.95396607065996 + }, + { + "x": 287.83582809336195, + "y": 96.43358306013239 + } + ], + [ + { + "x": 287.83582809336195, + "y": 96.43358306013239 + }, + { + "x": 285.8792142582439, + "y": 86.96261764628767 + } + ], + [ + { + "x": 287.83582809336195, + "y": 96.43358306013239 + }, + { + "x": 275.56484934225557, + "y": 110.60010251391168 + } + ], + [ + { + "x": 275.56484934225557, + "y": 110.60010251391168 + }, + { + "x": 273.3901450234493, + "y": 113.54181379158128 + } + ], + [ + { + "x": 405.78175099235244, + "y": 61.64085009635142 + }, + { + "x": 405.98914770706506, + "y": 61.64475306800944 + } + ], + [ + { + "x": 478.1752152705529, + "y": 263.64721959428994 + }, + { + "x": 482.17844915471926, + "y": 276.93009623205495 + } + ], + [ + { + "x": 505.2025327843412, + "y": 214.81294278313706 + }, + { + "x": 506.2894212035501, + "y": 215.32701522807386 + } + ], + [ + { + "x": 258.9287117144893, + "y": 123.31841182717427 + }, + { + "x": 256.2922918135843, + "y": 124.6440420385428 + } + ], + [ + { + "x": 133.03452630267716, + "y": 120.56885308509187 + }, + { + "x": 115.55253576755939, + "y": 127.77283715496863 + } + ], + [ + { + "x": 133.03452630267716, + "y": 120.56885308509187 + }, + { + "x": 133.27524428107301, + "y": 120.44170985990107 + } + ], + [ + { + "x": 482.17844915471926, + "y": 276.93009623205495 + }, + { + "x": 485.8459590846974, + "y": 281.8542222750358 + } + ], + [ + { + "x": 485.8459590846974, + "y": 281.8542222750358 + }, + { + "x": 486.5732529761684, + "y": 283.82311742621727 + } + ], + [ + { + "x": 256.2922918135843, + "y": 124.6440420385428 + }, + { + "x": 248.0767215873927, + "y": 129.51210230488735 + } + ], + [ + { + "x": 405.98914770706506, + "y": 61.64475306800944 + }, + { + "x": 418.04071315431014, + "y": 60.635435156741686 + } + ], + [ + { + "x": 418.04071315431014, + "y": 60.635435156741686 + }, + { + "x": 423.4945531986324, + "y": 61.09630169067084 + } + ], + [ + { + "x": 486.5732529761684, + "y": 283.82311742621727 + }, + { + "x": 488.3334394658448, + "y": 285.68600140764266 + } + ], + [ + { + "x": 488.3334394658448, + "y": 285.68600140764266 + }, + { + "x": 491.53350845842067, + "y": 291.5913973323966 + } + ], + [ + { + "x": 423.4945531986324, + "y": 61.09630169067084 + }, + { + "x": 426.94369686388944, + "y": 60.8700238480915 + } + ], + [ + { + "x": 426.94369686388944, + "y": 60.8700238480915 + }, + { + "x": 433.13359118983544, + "y": 59.22902276649376 + } + ], + [ + { + "x": 506.2894212035501, + "y": 215.32701522807386 + }, + { + "x": 509.3902187069389, + "y": 215.9511658560672 + } + ], + [ + { + "x": 509.3902187069389, + "y": 215.9511658560672 + }, + { + "x": 514.9985045981061, + "y": 215.95116585606692 + } + ], + [ + { + "x": 115.55253576755939, + "y": 127.77283715496863 + }, + { + "x": 110.11952421689135, + "y": 128.84739082632132 + } + ], + [ + { + "x": 491.53350845842067, + "y": 291.5913973323966 + }, + { + "x": 491.9263114647667, + "y": 294.96213456391547 + } + ], + [ + { + "x": 433.13359118983544, + "y": 59.22902276649376 + }, + { + "x": 433.44285837951077, + "y": 59.21167583541018 + } + ], + [ + { + "x": 195.55832773322336, + "y": 118.6649859817357 + }, + { + "x": 194.1955893698774, + "y": 112.68844342486503 + } + ], + [ + { + "x": 195.55832773322336, + "y": 118.6649859817357 + }, + { + "x": 178.94067714608974, + "y": 113.63602906448565 + } + ], + [ + { + "x": 491.9263114647667, + "y": 294.96213456391547 + }, + { + "x": 496.9066746069053, + "y": 301.610201354817 + } + ], + [ + { + "x": 496.9066746069053, + "y": 301.610201354817 + }, + { + "x": 497.8528005277029, + "y": 302.1213681599282 + } + ], + [ + { + "x": 433.44285837951077, + "y": 59.21167583541018 + }, + { + "x": 443.04882399115183, + "y": 57.422796524579 + } + ], + [ + { + "x": 443.04882399115183, + "y": 57.422796524579 + }, + { + "x": 448.10594339483487, + "y": 55.735178221114225 + } + ], + [ + { + "x": 514.9985045981061, + "y": 215.95116585606692 + }, + { + "x": 518.1476829204133, + "y": 216.8371659561265 + } + ], + [ + { + "x": 518.1476829204133, + "y": 216.8371659561265 + }, + { + "x": 528.1394461615695, + "y": 217.43765721634637 + } + ], + [ + { + "x": 497.8528005277029, + "y": 302.1213681599282 + }, + { + "x": 500.47261855649384, + "y": 305.2436031548176 + } + ], + [ + { + "x": 448.10594339483487, + "y": 55.735178221114225 + }, + { + "x": 454.3421431720128, + "y": 55.1372429477216 + } + ], + [ + { + "x": 102.67156501946316, + "y": 131.25430325438955 + }, + { + "x": 105.6370769481479, + "y": 130.65826730368994 + } + ], + [ + { + "x": 102.67156501946316, + "y": 131.25430325438955 + }, + { + "x": 100.71620949747984, + "y": 131.26957904138072 + } + ], + [ + { + "x": 178.94067714608974, + "y": 113.63602906448565 + }, + { + "x": 173.83764027333171, + "y": 112.63383085357049 + } + ], + [ + { + "x": 500.47261855649384, + "y": 305.2436031548176 + }, + { + "x": 500.7728814233292, + "y": 306.6272110513359 + } + ], + [ + { + "x": 454.3421431720128, + "y": 55.1372429477216 + }, + { + "x": 459.9699751529196, + "y": 53.52748495785983 + } + ], + [ + { + "x": 459.9699751529196, + "y": 53.52748495785983 + }, + { + "x": 465.23506136030335, + "y": 53.246853909185894 + } + ], + [ + { + "x": 528.1394461615695, + "y": 217.43765721634637 + }, + { + "x": 529.9639202468364, + "y": 218.1624667767026 + } + ], + [ + { + "x": 173.83764027333171, + "y": 112.63383085357049 + }, + { + "x": 159.78812757526987, + "y": 88.3115532152507 + } + ], + [ + { + "x": 173.83764027333171, + "y": 112.63383085357049 + }, + { + "x": 170.45022006025582, + "y": 112.77168320147 + } + ], + [ + { + "x": 529.9639202468364, + "y": 218.1624667767026 + }, + { + "x": 540.1579194932982, + "y": 219.647334740655 + } + ], + [ + { + "x": 105.6370769481479, + "y": 130.65826730368994 + }, + { + "x": 110.11952421689135, + "y": 128.84739082632132 + } + ], + [ + { + "x": 170.45022006025582, + "y": 112.77168320147 + }, + { + "x": 147.99525551117816, + "y": 115.60115178319484 + } + ], + [ + { + "x": 465.23506136030335, + "y": 53.246853909185894 + }, + { + "x": 476.6124260148762, + "y": 50.62595058999021 + } + ], + [ + { + "x": 476.6124260148762, + "y": 50.62595058999021 + }, + { + "x": 479.4713335954088, + "y": 49.39049094059168 + } + ], + [ + { + "x": 147.99525551117816, + "y": 115.60115178319484 + }, + { + "x": 143.47071375576357, + "y": 116.7776570745938 + } + ], + [ + { + "x": 479.4713335954088, + "y": 49.39049094059168 + }, + { + "x": 479.9534039501394, + "y": 49.332633018564785 + } + ], + [ + { + "x": 143.47071375576357, + "y": 116.7776570745938 + }, + { + "x": 133.27524428107301, + "y": 120.44170985990107 + } + ], + [ + { + "x": 540.1579194932982, + "y": 219.647334740655 + }, + { + "x": 540.5763694335384, + "y": 219.56265099487962 + } + ], + [ + { + "x": 479.9534039501394, + "y": 49.332633018564785 + }, + { + "x": 483.8257543111303, + "y": 48.22499198797036 + } + ], + [ + { + "x": 540.5763694335384, + "y": 219.56265099487962 + }, + { + "x": 550.614276435025, + "y": 221.63630688804375 + } + ], + [ + { + "x": 100.71620949747984, + "y": 131.26957904138072 + }, + { + "x": 94.70169833715312, + "y": 130.0407679953584 + } + ], + [ + { + "x": 483.8257543111303, + "y": 48.22499198797036 + }, + { + "x": 487.9612590131488, + "y": 46.48546394919099 + } + ], + [ + { + "x": 550.614276435025, + "y": 221.63630688804375 + }, + { + "x": 560.0765675479196, + "y": 220.0252688156484 + } + ], + [ + { + "x": 487.9612590131488, + "y": 46.48546394919099 + }, + { + "x": 497.82892376422654, + "y": 44.67625428332226 + } + ], + [ + { + "x": 497.82892376422654, + "y": 44.67625428332226 + }, + { + "x": 501.4405397899933, + "y": 43.241467734518004 + } + ], + [ + { + "x": 94.70169833715312, + "y": 130.0407679953584 + }, + { + "x": 89.95650085513392, + "y": 128.0822549997453 + } + ], + [ + { + "x": 501.4405397899933, + "y": 43.241467734518004 + }, + { + "x": 505.30442121108626, + "y": 43.13910924411935 + } + ], + [ + { + "x": 560.0765675479196, + "y": 220.0252688156484 + }, + { + "x": 561.8944393758946, + "y": 220.8120769637511 + } + ], + [ + { + "x": 561.8944393758946, + "y": 220.8120769637511 + }, + { + "x": 571.0135588111234, + "y": 220.9983224167926 + } + ], + [ + { + "x": 505.30442121108626, + "y": 43.13910924411935 + }, + { + "x": 513.874901571454, + "y": 40.864635057356146 + } + ], + [ + { + "x": 89.95650085513392, + "y": 128.0822549997453 + }, + { + "x": 89.52599676040396, + "y": 128.02667060507963 + } + ], + [ + { + "x": 89.52599676040396, + "y": 128.02667060507963 + }, + { + "x": 82.04398409801168, + "y": 125.41893044476957 + } + ], + [ + { + "x": 571.0135588111234, + "y": 220.9983224167926 + }, + { + "x": 574.4003384425555, + "y": 222.30533867257213 + } + ], + [ + { + "x": 513.874901571454, + "y": 40.864635057356146 + }, + { + "x": 513.9293281107559, + "y": 40.83510978685411 + } + ], + [ + { + "x": 82.01314328513119, + "y": 125.4295769782849 + }, + { + "x": 82.04398409801168, + "y": 125.41893044476957 + } + ], + [ + { + "x": 574.4003384425555, + "y": 222.30533867257213 + }, + { + "x": 579.7180228218145, + "y": 221.3874856352264 + } + ], + [ + { + "x": 513.9293281107559, + "y": 40.83510978685411 + }, + { + "x": 519.5105273485341, + "y": 39.65592751997516 + } + ], + [ + { + "x": 82.04398409801168, + "y": 125.41893044476957 + }, + { + "x": 70.74092265859326, + "y": 114.02365560579803 + } + ], + [ + { + "x": 70.74092265859326, + "y": 114.02365560579803 + }, + { + "x": 61.96674041485678, + "y": 110.14841328676359 + } + ], + [ + { + "x": 519.5105273485341, + "y": 39.65592751997516 + }, + { + "x": 526.761562854262, + "y": 35.35965597691259 + } + ], + [ + { + "x": 579.7180228218145, + "y": 221.3874856352264 + }, + { + "x": 585.0931872927339, + "y": 223.35011030992843 + } + ], + [ + { + "x": 526.761562854262, + "y": 35.35965597691259 + }, + { + "x": 528.3894706676772, + "y": 35.267750804430094 + } + ], + [ + { + "x": 585.0931872927339, + "y": 223.35011030992843 + }, + { + "x": 592.0875281344113, + "y": 221.34930280665594 + } + ], + [ + { + "x": 61.96674041485678, + "y": 110.14841328676359 + }, + { + "x": 61.22713383848892, + "y": 109.38684685409505 + } + ], + [ + { + "x": 61.22713383848892, + "y": 109.38684685409505 + }, + { + "x": 52.83602015686794, + "y": 105.52960625068711 + } + ], + [ + { + "x": 528.3894706676772, + "y": 35.267750804430094 + }, + { + "x": 534.7482093164325, + "y": 32.73334693987367 + } + ], + [ + { + "x": 592.0875281344113, + "y": 221.34930280665594 + }, + { + "x": 594.325248755682, + "y": 222.70699676507846 + } + ], + [ + { + "x": 594.325248755682, + "y": 222.70699676507846 + }, + { + "x": 598.1739972776145, + "y": 222.29941243781875 + } + ], + [ + { + "x": 534.7482093164325, + "y": 32.73334693987367 + }, + { + "x": 536.7736997711603, + "y": 30.25043031681742 + } + ], + [ + { + "x": 52.83602015686794, + "y": 105.52960625068711 + }, + { + "x": 51.57407983372714, + "y": 104.15458046484554 + } + ], + [ + { + "x": 51.57407983372714, + "y": 104.15458046484554 + }, + { + "x": 45.22358035225297, + "y": 102.19575829610763 + } + ], + [ + { + "x": 536.7736997711603, + "y": 30.25043031681742 + }, + { + "x": 545.5341845713319, + "y": 26.8642185578681 + } + ], + [ + { + "x": 545.5341845713319, + "y": 26.8642185578681 + }, + { + "x": 546.7679455562375, + "y": 24.931748732169883 + } + ], + [ + { + "x": 45.22358035225297, + "y": 102.19575829610763 + }, + { + "x": 42.264677791160786, + "y": 99.17308008311863 + } + ], + [ + { + "x": 546.7679455562375, + "y": 24.931748732169883 + }, + { + "x": 554.3366724317648, + "y": 22.612542111221813 + } + ], + [ + { + "x": 42.264677791160786, + "y": 99.17308008311863 + }, + { + "x": 37.06168960696543, + "y": 98.46423652532214 + } + ], + [ + { + "x": 554.3366724317648, + "y": 22.612542111221813 + }, + { + "x": 555.1968349371222, + "y": 20.15219278845622 + } + ], + [ + { + "x": 37.06168960696543, + "y": 98.46423652532214 + }, + { + "x": 32.857857568260606, + "y": 93.31094352459202 + } + ] +] diff --git a/src/main/resources/static/css/pages/editor.css b/src/main/resources/static/css/pages/editor.css index d65a9edc..257f75b0 100644 --- a/src/main/resources/static/css/pages/editor.css +++ b/src/main/resources/static/css/pages/editor.css @@ -108,7 +108,7 @@ display: flex; justify-content: space-between; background-color: var(--bg-color-emphasis); - padding: 0.25rem; + padding: 0.375rem; border-top-left-radius: inherit; border-top-right-radius: inherit; @@ -122,6 +122,18 @@ border-bottom-right-radius: inherit; } +.dv-source-card-hint:only-child { + display: flex; + justify-content: center; + border-top: 1px solid var(--border-color); + font-size: var(--font-size-sm); + padding: 0.25rem; +} + +.dv-source-card-hint { + display: none; +} + .dv-add-source-button { width: 100%; display: flex; @@ -132,6 +144,7 @@ padding: 0.375rem 0.75rem; border: none; border-radius: var(--border-radius); + margin-top: 1rem; transition: all 300ms ease; &:hover { @@ -158,19 +171,29 @@ .dv-generator-card { display: flex; - justify-content: space-between; - align-items: flex-start; + flex-direction: column; background-color: var(--bg-color); - padding: 0.375rem 0.25rem; + padding: 0.375rem; border-top: 1px solid var(--border-color); border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; } -.dv-generator-card-title { +.dv-generator-card-header { display: flex; + justify-content: space-between; gap: 0.375rem; overflow: hidden; + + button { + padding: 0.125rem 0.375rem; + } +} + +.dv-generator-card-title { + display: flex; + align-items: center; + gap: 0.375rem; } .dv-generator-card-token { @@ -197,7 +220,7 @@ white-space: nowrap; } -.dv-generator-card-buttons { +.dv-generator-card-body { display: flex; gap: 0.25rem; @@ -206,21 +229,28 @@ } } -.dv-available-widgets-container { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; +.dv-container-title { + font-weight: 600; + margin: 1rem 0 0.5rem 0; +} + +.dv-static-widgets-container, +.dv-dynamic-widgets-container { + display: grid; + grid-template-columns: 1fr 1fr 1fr; } .dv-available-widget { display: flex; flex-direction: column; align-items: center; + padding: 0.3rem; + overflow: hidden; } .dv-available-widget-draggable { - width: 5rem; - height: 5rem; + width: 100%; + aspect-ratio: 1/1; display: flex; justify-content: center; align-items: center; @@ -246,7 +276,7 @@ } .dv-available-widget-title { - width: 5rem; + width: 100%; font-size: var(--font-size-sm); text-align: center; overflow: hidden; diff --git a/src/main/resources/static/css/shared/chart.css b/src/main/resources/static/css/shared/chart.css index aa4796a0..166e2329 100644 --- a/src/main/resources/static/css/shared/chart.css +++ b/src/main/resources/static/css/shared/chart.css @@ -66,10 +66,10 @@ display: flex; flex-direction: column; background-color: var(--bg-color); - padding: 1rem; + padding: 0.5rem; opacity: 0.97; transition: all 300ms ease-in-out; - z-index: 10; + z-index: 20; } .dv-sidepanel.show { @@ -82,10 +82,94 @@ display: flex; align-items: center; justify-content: space-between; - margin-bottom: 1rem; + padding: 0.5rem; } .dv-sidepanel-body { flex-grow: 1; overflow: auto; + padding: 0.5rem; +} + +.dv-pagination-controls { + opacity: 0; + transition: opacity 300ms ease; + transition-delay: 800ms; +} + +.dv-pagination-dropdown { + top: 1rem; + right: 1rem; + max-width: 20rem; + appearance: none; + border-radius: var(--border-radius); + padding: 0.25rem 2.25rem 0.25rem 0.5rem; + cursor: pointer; + background-image: url("/img/chevron.svg"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 10px; +} + +.dv-pagination-indicator { + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + font-variant-numeric: tabular-nums; /* prevents layout shift as numbers change */ + border-radius: 10px; + padding: 2px 8px; + white-space: nowrap; + pointer-events: none; +} + +.dv-btn-pagination { + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + color: #444; + border-radius: 50%; + cursor: pointer; + transition: + background 300ms ease, + box-shadow 300ms ease; +} + +.dv-pagination-dropdown, +.dv-pagination-indicator, +.dv-btn-pagination { + position: absolute; + color: #444; + background-color: var(--bg-color-emphasis); + border: 1px solid rgba(0, 0, 0, 0.12); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); + z-index: 10; +} + +.dv-chart-area:hover { + .dv-pagination-controls { + opacity: 0.9; + transition-delay: 0ms; + } +} + +.dv-btn-pagination:hover { + background: var(--hover-color); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.dv-btn-pagination:active { + background: var(--active-color); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.dv-btn-pagination.prev { + left: 1rem; +} + +.dv-btn-pagination.next { + right: 1rem; } diff --git a/src/main/resources/static/css/shared/chatbot.css b/src/main/resources/static/css/shared/chatbot.css index 03ed932c..5d917b67 100644 --- a/src/main/resources/static/css/shared/chatbot.css +++ b/src/main/resources/static/css/shared/chatbot.css @@ -159,6 +159,13 @@ font-size: 0.75rem; font-weight: bold; } + + img { + width: 100%; + background-color: var(--bg-color); + border-radius: 3px; + margin: 0.375rem 0; + } } .dv-chat-input { diff --git a/src/main/resources/static/css/shared/components.css b/src/main/resources/static/css/shared/components.css index e4ec8c4a..8245e268 100644 --- a/src/main/resources/static/css/shared/components.css +++ b/src/main/resources/static/css/shared/components.css @@ -131,29 +131,32 @@ body.modal-open { .dv-modal { position: absolute; + display: flex; + flex-direction: column; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 25rem; + max-height: 80vh; background-color: var(--bg-color); + padding: 0.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow); - padding: 1rem; } .dv-modal-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; + padding: 0.5rem; } .dv-modal-body { display: flex; flex-direction: column; - max-height: 60vh; - margin-bottom: 1rem; - overflow: auto; + height: 100%; + padding: 0.5rem; + overflow-y: auto; } .dv-modal-body-spinner { @@ -163,13 +166,13 @@ body.modal-open { justify-content: center; gap: 1rem; font-weight: bold; - margin-top: 1rem; } .dv-modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; + padding: 0.5rem; } .dv-dropdown-container { diff --git a/src/main/resources/static/css/shared/controls.css b/src/main/resources/static/css/shared/controls.css index feeb8366..e9d1f364 100644 --- a/src/main/resources/static/css/shared/controls.css +++ b/src/main/resources/static/css/shared/controls.css @@ -346,6 +346,8 @@ output { border: none; outline: none; flex-grow: 1; + text-overflow: ellipsis; + direction: rtl; } i { @@ -396,3 +398,9 @@ output { outline: 1px solid var(--primary); } } + +.dv-invalid-feedback { + display: none; + color: var(--danger); + font-size: var(--font-size-sm); +} diff --git a/src/main/resources/static/css/shared/globals.css b/src/main/resources/static/css/shared/globals.css index 8a7e4758..55a336c4 100644 --- a/src/main/resources/static/css/shared/globals.css +++ b/src/main/resources/static/css/shared/globals.css @@ -10,6 +10,11 @@ font-size: var(--font-size-lg); } +.dv-bordered { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} + .dv-centered { display: flex; justify-content: center; @@ -22,6 +27,13 @@ white-space: nowrap; } +.dv-text-truncate-left { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; +} + .dv-divider { padding-bottom: 0.2rem; border-bottom: 1px solid var(--border-color); diff --git a/src/main/resources/static/data/BarChart.json b/src/main/resources/static/data/BarChart.json new file mode 100644 index 00000000..45099eb3 --- /dev/null +++ b/src/main/resources/static/data/BarChart.json @@ -0,0 +1,17 @@ +[ + { + "label": "Label 1", + "value": 140, + "color": "#00618f" + }, + { + "label": "Label 2", + "value": 73, + "color": "#3a4856" + }, + { + "label": "Label 3", + "value": 56, + "color": "#9eadbd" + } +] diff --git a/src/main/resources/static/data/BoundaryApproximation.json b/src/main/resources/static/data/BoundaryApproximation.json new file mode 100644 index 00000000..563f3413 --- /dev/null +++ b/src/main/resources/static/data/BoundaryApproximation.json @@ -0,0 +1,1982 @@ +[ + [ + { + "x": 276.4694937085933, + "y": 210.96513455773257 + }, + { + "x": 231.44104269965737, + "y": 212.1801872596352 + } + ], + [ + { + "x": 279.8532649869678, + "y": 233.57060329542853 + }, + { + "x": 258.5205158524833, + "y": 232.82647918776243 + } + ], + [ + { + "x": 258.5205158524833, + "y": 232.82647918776243 + }, + { + "x": 241.73193780364903, + "y": 233.5652548110095 + } + ], + [ + { + "x": 354.0914634285864, + "y": 159.91399739642264 + }, + { + "x": 344.64352898661446, + "y": 156.26693179627298 + } + ], + [ + { + "x": 354.0914634285864, + "y": 159.91399739642264 + }, + { + "x": 355.3915585448194, + "y": 160.5893335465575 + } + ], + [ + { + "x": 298.0951585828476, + "y": 253.41146322242082 + }, + { + "x": 296.1249795167421, + "y": 253.9237691377335 + } + ], + [ + { + "x": 265.24532194434516, + "y": 280.9354551743008 + }, + { + "x": 262.5295657746439, + "y": 275.52695423311343 + } + ], + [ + { + "x": 265.24532194434516, + "y": 280.9354551743008 + }, + { + "x": 268.52959214005756, + "y": 291.5223584194478 + } + ], + [ + { + "x": 265.24532194434516, + "y": 280.9354551743008 + }, + { + "x": 298.60071254578173, + "y": 288.5246866833821 + } + ], + [ + { + "x": 296.1249795167421, + "y": 253.9237691377335 + }, + { + "x": 251.84475110138965, + "y": 255.84281760512903 + } + ], + [ + { + "x": 306.5044338898832, + "y": 276.4773984066239 + }, + { + "x": 295.4518779399672, + "y": 274.6708656405246 + } + ], + [ + { + "x": 295.4518779399672, + "y": 274.6708656405246 + }, + { + "x": 262.5295657746439, + "y": 275.52695423311343 + } + ], + [ + { + "x": 262.5295657746439, + "y": 275.52695423311343 + }, + { + "x": 257.0016149623103, + "y": 268.3043144390161 + } + ], + [ + { + "x": 257.0016149623103, + "y": 268.3043144390161 + }, + { + "x": 251.84475110138965, + "y": 255.84281760512903 + } + ], + [ + { + "x": 257.0016149623103, + "y": 268.3043144390161 + }, + { + "x": 248.7974160356412, + "y": 280.5476332931568 + } + ], + [ + { + "x": 251.84475110138965, + "y": 255.84281760512903 + }, + { + "x": 250.4395054824601, + "y": 253.70056816871642 + } + ], + [ + { + "x": 355.3915585448194, + "y": 160.5893335465575 + }, + { + "x": 362.7271876763521, + "y": 163.38058961864206 + } + ], + [ + { + "x": 342.1290274358434, + "y": 155.55973640332618 + }, + { + "x": 344.64352898661446, + "y": 156.26693179627298 + } + ], + [ + { + "x": 342.1290274358434, + "y": 155.55973640332618 + }, + { + "x": 326.2469584444125, + "y": 148.66787666927289 + } + ], + [ + { + "x": 326.2469584444125, + "y": 148.66787666927289 + }, + { + "x": 318.71725758629844, + "y": 144.4158418049187 + } + ], + [ + { + "x": 295.0487668801159, + "y": 132.81409188679584 + }, + { + "x": 318.71725758629844, + "y": 144.4158418049187 + } + ], + [ + { + "x": 295.0487668801159, + "y": 132.81409188679584 + }, + { + "x": 270.27799781044007, + "y": 118.42217605259567 + } + ], + [ + { + "x": 362.7271876763521, + "y": 163.38058961864206 + }, + { + "x": 363.4542635879794, + "y": 163.75087253630252 + } + ], + [ + { + "x": 363.4542635879794, + "y": 163.75087253630252 + }, + { + "x": 365.74821640103215, + "y": 165.2516313899089 + } + ], + [ + { + "x": 365.74821640103215, + "y": 165.2516313899089 + }, + { + "x": 370.5318289932407, + "y": 167.27611370781707 + } + ], + [ + { + "x": 370.5318289932407, + "y": 167.27611370781707 + }, + { + "x": 382.695274511083, + "y": 174.39253382785813 + } + ], + [ + { + "x": 382.695274511083, + "y": 174.39253382785813 + }, + { + "x": 383.6933439412148, + "y": 175.17355957148266 + } + ], + [ + { + "x": 383.6933439412148, + "y": 175.17355957148266 + }, + { + "x": 384.31128021867676, + "y": 175.4579812619773 + } + ], + [ + { + "x": 384.31128021867676, + "y": 175.4579812619773 + }, + { + "x": 399.22012219724786, + "y": 184.93445956972047 + } + ], + [ + { + "x": 399.22012219724786, + "y": 184.93445956972047 + }, + { + "x": 399.44337301891267, + "y": 185.13699113589476 + } + ], + [ + { + "x": 399.44337301891267, + "y": 185.13699113589476 + }, + { + "x": 414.9812814645312, + "y": 193.67248550284344 + } + ], + [ + { + "x": 414.9812814645312, + "y": 193.67248550284344 + }, + { + "x": 418.9159865180896, + "y": 194.71940319196102 + } + ], + [ + { + "x": 418.9159865180896, + "y": 194.71940319196102 + }, + { + "x": 428.6565810384989, + "y": 199.92131198286734 + } + ], + [ + { + "x": 303.0569279480815, + "y": 312.93992939747466 + }, + { + "x": 278.90149932292803, + "y": 311.98031759010854 + } + ], + [ + { + "x": 278.90149932292803, + "y": 311.98031759010854 + }, + { + "x": 283.38973920335974, + "y": 323.12917619053445 + } + ], + [ + { + "x": 278.90149932292803, + "y": 311.98031759010854 + }, + { + "x": 277.3417775663491, + "y": 309.5817435279197 + } + ], + [ + { + "x": 428.6565810384989, + "y": 199.92131198286734 + }, + { + "x": 432.7556586672885, + "y": 203.09339467829565 + } + ], + [ + { + "x": 287.11831209783327, + "y": 330.41302506372415 + }, + { + "x": 283.5713242017622, + "y": 323.84335706016253 + } + ], + [ + { + "x": 287.11831209783327, + "y": 330.41302506372415 + }, + { + "x": 287.92291446826823, + "y": 332.6856735638844 + } + ], + [ + { + "x": 432.7556586672885, + "y": 203.09339467829565 + }, + { + "x": 441.3975374800467, + "y": 206.67478407764185 + } + ], + [ + { + "x": 361.3040048990012, + "y": 63.61195904904777 + }, + { + "x": 339.15871092445195, + "y": 66.71700864883806 + } + ], + [ + { + "x": 361.3040048990012, + "y": 63.61195904904777 + }, + { + "x": 364.42494915076975, + "y": 63.82200603457633 + } + ], + [ + { + "x": 287.92291446826823, + "y": 332.6856735638844 + }, + { + "x": 289.1764351271848, + "y": 340.5948470485398 + } + ], + [ + { + "x": 441.3975374800467, + "y": 206.67478407764185 + }, + { + "x": 447.39747291642686, + "y": 210.37431788939247 + } + ], + [ + { + "x": 390.1548582411179, + "y": 67.53150032675359 + }, + { + "x": 391.08696064175916, + "y": 69.8839770573038 + } + ], + [ + { + "x": 390.1548582411179, + "y": 67.53150032675359 + }, + { + "x": 389.37999584841094, + "y": 64.2068812820587 + } + ], + [ + { + "x": 447.39747291642686, + "y": 210.37431788939247 + }, + { + "x": 451.32642608484963, + "y": 211.80833118851157 + } + ], + [ + { + "x": 283.38973920335974, + "y": 323.12917619053445 + }, + { + "x": 283.5713242017622, + "y": 323.84335706016253 + } + ], + [ + { + "x": 277.1977060368536, + "y": 309.16964870547844 + }, + { + "x": 277.3417775663491, + "y": 309.5817435279197 + } + ], + [ + { + "x": 277.1977060368536, + "y": 309.16964870547844 + }, + { + "x": 268.52959214005756, + "y": 291.5223584194478 + } + ], + [ + { + "x": 246.1330571447316, + "y": 245.2122689356378 + }, + { + "x": 241.73193780364903, + "y": 233.5652548110095 + } + ], + [ + { + "x": 246.1330571447316, + "y": 245.2122689356378 + }, + { + "x": 250.4395054824601, + "y": 253.70056816871642 + } + ], + [ + { + "x": 335.99036460271645, + "y": 67.55354638429986 + }, + { + "x": 318.43944775426735, + "y": 75.3669914276231 + } + ], + [ + { + "x": 335.99036460271645, + "y": 67.55354638429986 + }, + { + "x": 339.15871092445195, + "y": 66.71700864883806 + } + ], + [ + { + "x": 241.73193780364903, + "y": 233.5652548110095 + }, + { + "x": 234.5489911050872, + "y": 221.09881210635803 + } + ], + [ + { + "x": 451.32642608484963, + "y": 211.80833118851157 + }, + { + "x": 461.49828856910466, + "y": 217.07679508084726 + } + ], + [ + { + "x": 234.5489911050872, + "y": 221.09881210635803 + }, + { + "x": 234.40093114972322, + "y": 220.77098047332987 + } + ], + [ + { + "x": 234.40093114972322, + "y": 220.77098047332987 + }, + { + "x": 231.44104269965737, + "y": 212.1801872596352 + } + ], + [ + { + "x": 301.5458412055822, + "y": 349.7927024163668 + }, + { + "x": 295.7460170435482, + "y": 350.41479289952423 + } + ], + [ + { + "x": 231.44104269965737, + "y": 212.1801872596352 + }, + { + "x": 229.66326782073838, + "y": 208.48075523428045 + } + ], + [ + { + "x": 463.75836241664285, + "y": 215.92923364434284 + }, + { + "x": 461.49828856910466, + "y": 217.07679508084726 + } + ], + [ + { + "x": 463.75836241664285, + "y": 215.92923364434284 + }, + { + "x": 469.9771368995175, + "y": 213.95285826417518 + } + ], + [ + { + "x": 295.7460170435482, + "y": 350.41479289952423 + }, + { + "x": 299.8402481743836, + "y": 359.78366242908027 + } + ], + [ + { + "x": 295.7460170435482, + "y": 350.41479289952423 + }, + { + "x": 294.00022137633005, + "y": 348.99354222535413 + } + ], + [ + { + "x": 469.9771368995175, + "y": 213.95285826417518 + }, + { + "x": 484.1425740928738, + "y": 211.3218153725464 + } + ], + [ + { + "x": 201.96147559195273, + "y": 224.99610493724492 + }, + { + "x": 215.69938872206262, + "y": 212.1822546999139 + } + ], + [ + { + "x": 215.69938872206262, + "y": 212.1822546999139 + }, + { + "x": 229.66326782073838, + "y": 208.48075523428045 + } + ], + [ + { + "x": 215.69938872206262, + "y": 212.1822546999139 + }, + { + "x": 202.46892694030836, + "y": 213.5047618071658 + } + ], + [ + { + "x": 289.1764351271848, + "y": 340.5948470485398 + }, + { + "x": 294.00022137633005, + "y": 348.99354222535413 + } + ], + [ + { + "x": 461.49828856910466, + "y": 217.07679508084726 + }, + { + "x": 462.30499934136424, + "y": 226.23456844885115 + } + ], + [ + { + "x": 300.70073122305394, + "y": 360.4868882105622 + }, + { + "x": 304.71815897220546, + "y": 368.04666605380226 + } + ], + [ + { + "x": 300.70073122305394, + "y": 360.4868882105622 + }, + { + "x": 299.8402481743836, + "y": 359.78366242908027 + } + ], + [ + { + "x": 229.66326782073838, + "y": 208.48075523428045 + }, + { + "x": 236.3756973040554, + "y": 137.48622582642756 + } + ], + [ + { + "x": 462.30499934136424, + "y": 226.23456844885115 + }, + { + "x": 462.7936573951168, + "y": 227.7606392277182 + } + ], + [ + { + "x": 184.71309518569234, + "y": 158.48500934411254 + }, + { + "x": 227.94895328489568, + "y": 134.3539906238046 + } + ], + [ + { + "x": 395.35004256678917, + "y": 62.80840399415247 + }, + { + "x": 389.37999584841094, + "y": 64.2068812820587 + } + ], + [ + { + "x": 395.35004256678917, + "y": 62.80840399415247 + }, + { + "x": 405.78175099235244, + "y": 61.64085009635142 + } + ], + [ + { + "x": 226.72879272195325, + "y": 133.79183874518966 + }, + { + "x": 218.23704745276075, + "y": 130.03806205298147 + } + ], + [ + { + "x": 226.72879272195325, + "y": 133.79183874518966 + }, + { + "x": 226.95701177950093, + "y": 133.88110873819332 + } + ], + [ + { + "x": 184.55368250522326, + "y": 221.20532621640197 + }, + { + "x": 191.80280014582843, + "y": 217.02499322895773 + } + ], + [ + { + "x": 465.48006967305435, + "y": 240.40216270930256 + }, + { + "x": 462.7936573951168, + "y": 227.7606392277182 + } + ], + [ + { + "x": 465.48006967305435, + "y": 240.40216270930256 + }, + { + "x": 470.2631550740469, + "y": 250.30929773975058 + } + ], + [ + { + "x": 470.2631550740469, + "y": 250.30929773975058 + }, + { + "x": 476.10496248262695, + "y": 257.30649526982756 + } + ], + [ + { + "x": 434.57067170175526, + "y": 84.36421603192149 + }, + { + "x": 426.94369686388944, + "y": 60.8700238480915 + } + ], + [ + { + "x": 364.42494915076975, + "y": 63.82200603457633 + }, + { + "x": 372.42115618470575, + "y": 63.36639521142976 + } + ], + [ + { + "x": 227.94895328489568, + "y": 134.3539906238046 + }, + { + "x": 236.3756973040554, + "y": 137.48622582642756 + } + ], + [ + { + "x": 227.94895328489568, + "y": 134.3539906238046 + }, + { + "x": 226.95701177950093, + "y": 133.88110873819332 + } + ], + [ + { + "x": 202.46892694030836, + "y": 213.5047618071658 + }, + { + "x": 165.30438885078752, + "y": 202.7205426418203 + } + ], + [ + { + "x": 202.46892694030836, + "y": 213.5047618071658 + }, + { + "x": 191.80280014582843, + "y": 217.02499322895773 + } + ], + [ + { + "x": 191.80280014582843, + "y": 217.02499322895773 + }, + { + "x": 187.82968299220013, + "y": 217.23167084533074 + } + ], + [ + { + "x": 218.23704745276075, + "y": 130.03806205298147 + }, + { + "x": 195.55832773322336, + "y": 118.6649859817357 + } + ], + [ + { + "x": 476.10496248262695, + "y": 257.30649526982756 + }, + { + "x": 478.2777113172204, + "y": 260.89941706375345 + } + ], + [ + { + "x": 385.68987687102435, + "y": 63.86911575158182 + }, + { + "x": 389.37999584841094, + "y": 64.2068812820587 + } + ], + [ + { + "x": 385.68987687102435, + "y": 63.86911575158182 + }, + { + "x": 383.42279908412814, + "y": 63.99829171383077 + } + ], + [ + { + "x": 236.3756973040554, + "y": 137.48622582642756 + }, + { + "x": 248.0767215873927, + "y": 129.51210230488735 + } + ], + [ + { + "x": 478.2777113172204, + "y": 260.89941706375345 + }, + { + "x": 478.1752152705529, + "y": 263.64721959428994 + } + ], + [ + { + "x": 478.2777113172204, + "y": 260.89941706375345 + }, + { + "x": 484.14454857266827, + "y": 260.7760729964323 + } + ], + [ + { + "x": 484.1425740928738, + "y": 211.3218153725464 + }, + { + "x": 491.5850548199624, + "y": 211.6154262352176 + } + ], + [ + { + "x": 270.27799781044007, + "y": 118.42217605259567 + }, + { + "x": 258.9287117144893, + "y": 123.31841182717427 + } + ], + [ + { + "x": 270.27799781044007, + "y": 118.42217605259567 + }, + { + "x": 273.3901450234493, + "y": 113.54181379158128 + } + ], + [ + { + "x": 491.5850548199624, + "y": 211.6154262352176 + }, + { + "x": 505.2025327843412, + "y": 214.81294278313706 + } + ], + [ + { + "x": 484.14454857266827, + "y": 260.7760729964323 + }, + { + "x": 490.67962875529753, + "y": 262.40139958947236 + } + ], + [ + { + "x": 372.42115618470575, + "y": 63.36639521142976 + }, + { + "x": 383.42279908412814, + "y": 63.99829171383077 + } + ], + [ + { + "x": 318.43944775426735, + "y": 75.3669914276231 + }, + { + "x": 315.4090260484865, + "y": 76.56369384292763 + } + ], + [ + { + "x": 309.6368036127734, + "y": 79.5906068574464 + }, + { + "x": 298.8971141987269, + "y": 86.95396607065996 + } + ], + [ + { + "x": 309.6368036127734, + "y": 79.5906068574464 + }, + { + "x": 315.4090260484865, + "y": 76.56369384292763 + } + ], + [ + { + "x": 298.8971141987269, + "y": 86.95396607065996 + }, + { + "x": 287.83582809336195, + "y": 96.43358306013239 + } + ], + [ + { + "x": 287.83582809336195, + "y": 96.43358306013239 + }, + { + "x": 285.8792142582439, + "y": 86.96261764628767 + } + ], + [ + { + "x": 287.83582809336195, + "y": 96.43358306013239 + }, + { + "x": 275.56484934225557, + "y": 110.60010251391168 + } + ], + [ + { + "x": 275.56484934225557, + "y": 110.60010251391168 + }, + { + "x": 273.3901450234493, + "y": 113.54181379158128 + } + ], + [ + { + "x": 405.78175099235244, + "y": 61.64085009635142 + }, + { + "x": 405.98914770706506, + "y": 61.64475306800944 + } + ], + [ + { + "x": 478.1752152705529, + "y": 263.64721959428994 + }, + { + "x": 482.17844915471926, + "y": 276.93009623205495 + } + ], + [ + { + "x": 505.2025327843412, + "y": 214.81294278313706 + }, + { + "x": 506.2894212035501, + "y": 215.32701522807386 + } + ], + [ + { + "x": 258.9287117144893, + "y": 123.31841182717427 + }, + { + "x": 256.2922918135843, + "y": 124.6440420385428 + } + ], + [ + { + "x": 133.03452630267716, + "y": 120.56885308509187 + }, + { + "x": 115.55253576755939, + "y": 127.77283715496863 + } + ], + [ + { + "x": 133.03452630267716, + "y": 120.56885308509187 + }, + { + "x": 133.27524428107301, + "y": 120.44170985990107 + } + ], + [ + { + "x": 482.17844915471926, + "y": 276.93009623205495 + }, + { + "x": 485.8459590846974, + "y": 281.8542222750358 + } + ], + [ + { + "x": 485.8459590846974, + "y": 281.8542222750358 + }, + { + "x": 486.5732529761684, + "y": 283.82311742621727 + } + ], + [ + { + "x": 256.2922918135843, + "y": 124.6440420385428 + }, + { + "x": 248.0767215873927, + "y": 129.51210230488735 + } + ], + [ + { + "x": 405.98914770706506, + "y": 61.64475306800944 + }, + { + "x": 418.04071315431014, + "y": 60.635435156741686 + } + ], + [ + { + "x": 418.04071315431014, + "y": 60.635435156741686 + }, + { + "x": 423.4945531986324, + "y": 61.09630169067084 + } + ], + [ + { + "x": 486.5732529761684, + "y": 283.82311742621727 + }, + { + "x": 488.3334394658448, + "y": 285.68600140764266 + } + ], + [ + { + "x": 488.3334394658448, + "y": 285.68600140764266 + }, + { + "x": 491.53350845842067, + "y": 291.5913973323966 + } + ], + [ + { + "x": 423.4945531986324, + "y": 61.09630169067084 + }, + { + "x": 426.94369686388944, + "y": 60.8700238480915 + } + ], + [ + { + "x": 426.94369686388944, + "y": 60.8700238480915 + }, + { + "x": 433.13359118983544, + "y": 59.22902276649376 + } + ], + [ + { + "x": 506.2894212035501, + "y": 215.32701522807386 + }, + { + "x": 509.3902187069389, + "y": 215.9511658560672 + } + ], + [ + { + "x": 509.3902187069389, + "y": 215.9511658560672 + }, + { + "x": 514.9985045981061, + "y": 215.95116585606692 + } + ], + [ + { + "x": 115.55253576755939, + "y": 127.77283715496863 + }, + { + "x": 110.11952421689135, + "y": 128.84739082632132 + } + ], + [ + { + "x": 491.53350845842067, + "y": 291.5913973323966 + }, + { + "x": 491.9263114647667, + "y": 294.96213456391547 + } + ], + [ + { + "x": 433.13359118983544, + "y": 59.22902276649376 + }, + { + "x": 433.44285837951077, + "y": 59.21167583541018 + } + ], + [ + { + "x": 195.55832773322336, + "y": 118.6649859817357 + }, + { + "x": 194.1955893698774, + "y": 112.68844342486503 + } + ], + [ + { + "x": 195.55832773322336, + "y": 118.6649859817357 + }, + { + "x": 178.94067714608974, + "y": 113.63602906448565 + } + ], + [ + { + "x": 491.9263114647667, + "y": 294.96213456391547 + }, + { + "x": 496.9066746069053, + "y": 301.610201354817 + } + ], + [ + { + "x": 496.9066746069053, + "y": 301.610201354817 + }, + { + "x": 497.8528005277029, + "y": 302.1213681599282 + } + ], + [ + { + "x": 433.44285837951077, + "y": 59.21167583541018 + }, + { + "x": 443.04882399115183, + "y": 57.422796524579 + } + ], + [ + { + "x": 443.04882399115183, + "y": 57.422796524579 + }, + { + "x": 448.10594339483487, + "y": 55.735178221114225 + } + ], + [ + { + "x": 514.9985045981061, + "y": 215.95116585606692 + }, + { + "x": 518.1476829204133, + "y": 216.8371659561265 + } + ], + [ + { + "x": 518.1476829204133, + "y": 216.8371659561265 + }, + { + "x": 528.1394461615695, + "y": 217.43765721634637 + } + ], + [ + { + "x": 497.8528005277029, + "y": 302.1213681599282 + }, + { + "x": 500.47261855649384, + "y": 305.2436031548176 + } + ], + [ + { + "x": 448.10594339483487, + "y": 55.735178221114225 + }, + { + "x": 454.3421431720128, + "y": 55.1372429477216 + } + ], + [ + { + "x": 102.67156501946316, + "y": 131.25430325438955 + }, + { + "x": 105.6370769481479, + "y": 130.65826730368994 + } + ], + [ + { + "x": 102.67156501946316, + "y": 131.25430325438955 + }, + { + "x": 100.71620949747984, + "y": 131.26957904138072 + } + ], + [ + { + "x": 178.94067714608974, + "y": 113.63602906448565 + }, + { + "x": 173.83764027333171, + "y": 112.63383085357049 + } + ], + [ + { + "x": 500.47261855649384, + "y": 305.2436031548176 + }, + { + "x": 500.7728814233292, + "y": 306.6272110513359 + } + ], + [ + { + "x": 454.3421431720128, + "y": 55.1372429477216 + }, + { + "x": 459.9699751529196, + "y": 53.52748495785983 + } + ], + [ + { + "x": 459.9699751529196, + "y": 53.52748495785983 + }, + { + "x": 465.23506136030335, + "y": 53.246853909185894 + } + ], + [ + { + "x": 528.1394461615695, + "y": 217.43765721634637 + }, + { + "x": 529.9639202468364, + "y": 218.1624667767026 + } + ], + [ + { + "x": 173.83764027333171, + "y": 112.63383085357049 + }, + { + "x": 159.78812757526987, + "y": 88.3115532152507 + } + ], + [ + { + "x": 173.83764027333171, + "y": 112.63383085357049 + }, + { + "x": 170.45022006025582, + "y": 112.77168320147 + } + ], + [ + { + "x": 529.9639202468364, + "y": 218.1624667767026 + }, + { + "x": 540.1579194932982, + "y": 219.647334740655 + } + ], + [ + { + "x": 105.6370769481479, + "y": 130.65826730368994 + }, + { + "x": 110.11952421689135, + "y": 128.84739082632132 + } + ], + [ + { + "x": 170.45022006025582, + "y": 112.77168320147 + }, + { + "x": 147.99525551117816, + "y": 115.60115178319484 + } + ], + [ + { + "x": 465.23506136030335, + "y": 53.246853909185894 + }, + { + "x": 476.6124260148762, + "y": 50.62595058999021 + } + ], + [ + { + "x": 476.6124260148762, + "y": 50.62595058999021 + }, + { + "x": 479.4713335954088, + "y": 49.39049094059168 + } + ], + [ + { + "x": 147.99525551117816, + "y": 115.60115178319484 + }, + { + "x": 143.47071375576357, + "y": 116.7776570745938 + } + ], + [ + { + "x": 479.4713335954088, + "y": 49.39049094059168 + }, + { + "x": 479.9534039501394, + "y": 49.332633018564785 + } + ], + [ + { + "x": 143.47071375576357, + "y": 116.7776570745938 + }, + { + "x": 133.27524428107301, + "y": 120.44170985990107 + } + ], + [ + { + "x": 540.1579194932982, + "y": 219.647334740655 + }, + { + "x": 540.5763694335384, + "y": 219.56265099487962 + } + ], + [ + { + "x": 479.9534039501394, + "y": 49.332633018564785 + }, + { + "x": 483.8257543111303, + "y": 48.22499198797036 + } + ], + [ + { + "x": 540.5763694335384, + "y": 219.56265099487962 + }, + { + "x": 550.614276435025, + "y": 221.63630688804375 + } + ], + [ + { + "x": 100.71620949747984, + "y": 131.26957904138072 + }, + { + "x": 94.70169833715312, + "y": 130.0407679953584 + } + ], + [ + { + "x": 483.8257543111303, + "y": 48.22499198797036 + }, + { + "x": 487.9612590131488, + "y": 46.48546394919099 + } + ], + [ + { + "x": 550.614276435025, + "y": 221.63630688804375 + }, + { + "x": 560.0765675479196, + "y": 220.0252688156484 + } + ], + [ + { + "x": 487.9612590131488, + "y": 46.48546394919099 + }, + { + "x": 497.82892376422654, + "y": 44.67625428332226 + } + ], + [ + { + "x": 497.82892376422654, + "y": 44.67625428332226 + }, + { + "x": 501.4405397899933, + "y": 43.241467734518004 + } + ], + [ + { + "x": 94.70169833715312, + "y": 130.0407679953584 + }, + { + "x": 89.95650085513392, + "y": 128.0822549997453 + } + ], + [ + { + "x": 501.4405397899933, + "y": 43.241467734518004 + }, + { + "x": 505.30442121108626, + "y": 43.13910924411935 + } + ], + [ + { + "x": 560.0765675479196, + "y": 220.0252688156484 + }, + { + "x": 561.8944393758946, + "y": 220.8120769637511 + } + ], + [ + { + "x": 561.8944393758946, + "y": 220.8120769637511 + }, + { + "x": 571.0135588111234, + "y": 220.9983224167926 + } + ], + [ + { + "x": 505.30442121108626, + "y": 43.13910924411935 + }, + { + "x": 513.874901571454, + "y": 40.864635057356146 + } + ], + [ + { + "x": 89.95650085513392, + "y": 128.0822549997453 + }, + { + "x": 89.52599676040396, + "y": 128.02667060507963 + } + ], + [ + { + "x": 89.52599676040396, + "y": 128.02667060507963 + }, + { + "x": 82.04398409801168, + "y": 125.41893044476957 + } + ], + [ + { + "x": 571.0135588111234, + "y": 220.9983224167926 + }, + { + "x": 574.4003384425555, + "y": 222.30533867257213 + } + ], + [ + { + "x": 513.874901571454, + "y": 40.864635057356146 + }, + { + "x": 513.9293281107559, + "y": 40.83510978685411 + } + ], + [ + { + "x": 82.01314328513119, + "y": 125.4295769782849 + }, + { + "x": 82.04398409801168, + "y": 125.41893044476957 + } + ], + [ + { + "x": 574.4003384425555, + "y": 222.30533867257213 + }, + { + "x": 579.7180228218145, + "y": 221.3874856352264 + } + ], + [ + { + "x": 513.9293281107559, + "y": 40.83510978685411 + }, + { + "x": 519.5105273485341, + "y": 39.65592751997516 + } + ], + [ + { + "x": 82.04398409801168, + "y": 125.41893044476957 + }, + { + "x": 70.74092265859326, + "y": 114.02365560579803 + } + ], + [ + { + "x": 70.74092265859326, + "y": 114.02365560579803 + }, + { + "x": 61.96674041485678, + "y": 110.14841328676359 + } + ], + [ + { + "x": 519.5105273485341, + "y": 39.65592751997516 + }, + { + "x": 526.761562854262, + "y": 35.35965597691259 + } + ], + [ + { + "x": 579.7180228218145, + "y": 221.3874856352264 + }, + { + "x": 585.0931872927339, + "y": 223.35011030992843 + } + ], + [ + { + "x": 526.761562854262, + "y": 35.35965597691259 + }, + { + "x": 528.3894706676772, + "y": 35.267750804430094 + } + ], + [ + { + "x": 585.0931872927339, + "y": 223.35011030992843 + }, + { + "x": 592.0875281344113, + "y": 221.34930280665594 + } + ], + [ + { + "x": 61.96674041485678, + "y": 110.14841328676359 + }, + { + "x": 61.22713383848892, + "y": 109.38684685409505 + } + ], + [ + { + "x": 61.22713383848892, + "y": 109.38684685409505 + }, + { + "x": 52.83602015686794, + "y": 105.52960625068711 + } + ], + [ + { + "x": 528.3894706676772, + "y": 35.267750804430094 + }, + { + "x": 534.7482093164325, + "y": 32.73334693987367 + } + ], + [ + { + "x": 592.0875281344113, + "y": 221.34930280665594 + }, + { + "x": 594.325248755682, + "y": 222.70699676507846 + } + ], + [ + { + "x": 594.325248755682, + "y": 222.70699676507846 + }, + { + "x": 598.1739972776145, + "y": 222.29941243781875 + } + ], + [ + { + "x": 534.7482093164325, + "y": 32.73334693987367 + }, + { + "x": 536.7736997711603, + "y": 30.25043031681742 + } + ], + [ + { + "x": 52.83602015686794, + "y": 105.52960625068711 + }, + { + "x": 51.57407983372714, + "y": 104.15458046484554 + } + ], + [ + { + "x": 51.57407983372714, + "y": 104.15458046484554 + }, + { + "x": 45.22358035225297, + "y": 102.19575829610763 + } + ], + [ + { + "x": 536.7736997711603, + "y": 30.25043031681742 + }, + { + "x": 545.5341845713319, + "y": 26.8642185578681 + } + ], + [ + { + "x": 545.5341845713319, + "y": 26.8642185578681 + }, + { + "x": 546.7679455562375, + "y": 24.931748732169883 + } + ], + [ + { + "x": 45.22358035225297, + "y": 102.19575829610763 + }, + { + "x": 42.264677791160786, + "y": 99.17308008311863 + } + ], + [ + { + "x": 546.7679455562375, + "y": 24.931748732169883 + }, + { + "x": 554.3366724317648, + "y": 22.612542111221813 + } + ], + [ + { + "x": 42.264677791160786, + "y": 99.17308008311863 + }, + { + "x": 37.06168960696543, + "y": 98.46423652532214 + } + ], + [ + { + "x": 554.3366724317648, + "y": 22.612542111221813 + }, + { + "x": 555.1968349371222, + "y": 20.15219278845622 + } + ], + [ + { + "x": 37.06168960696543, + "y": 98.46423652532214 + }, + { + "x": 32.857857568260606, + "y": 93.31094352459202 + } + ] +] diff --git a/src/main/resources/static/data/HighlightText.json b/src/main/resources/static/data/HighlightText.json new file mode 100644 index 00000000..dfff9847 --- /dev/null +++ b/src/main/resources/static/data/HighlightText.json @@ -0,0 +1,22 @@ +{ + "spans": [ + { + "text": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr", + "style": "text-decoration: underline 2px #00618f;" + }, + { + "text": ", sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. " + }, + { + "text": "Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", + "style": "text-decoration: underline 2px #3a4856;" + }, + { + "text": " Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. " + }, + { + "text": "Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", + "style": "text-decoration: underline 2px #9eadbd;" + } + ] +} diff --git a/src/main/resources/static/data/LineChart.json b/src/main/resources/static/data/LineChart.json new file mode 100644 index 00000000..0600bd28 --- /dev/null +++ b/src/main/resources/static/data/LineChart.json @@ -0,0 +1,32 @@ +[ + { + "name": "Dataset", + "color": "#00618f", + "coordinates": [ + { + "y": 5, + "x": 0 + }, + { + "y": 20, + "x": 20 + }, + { + "y": 10, + "x": 40 + }, + { + "y": 40, + "x": 60 + }, + { + "y": 5, + "x": 80 + }, + { + "y": 60, + "x": 100 + } + ] + } +] diff --git a/src/main/resources/static/data/MedialAxis.json b/src/main/resources/static/data/MedialAxis.json new file mode 100644 index 00000000..37b28c86 --- /dev/null +++ b/src/main/resources/static/data/MedialAxis.json @@ -0,0 +1,203 @@ +[ + { "x": -0.474464, "y": -0.255323 }, + { "x": -0.47282, "y": -0.232298 }, + { "x": -0.461308, "y": -0.21503 }, + { "x": -0.449795, "y": -0.201873 }, + { "x": -0.438283, "y": -0.190361 }, + { "x": -0.425949, "y": -0.17556 }, + { "x": -0.423482, "y": -0.147602 }, + { "x": -0.414437, "y": -0.127867 }, + { "x": -0.40128, "y": -0.10731 }, + { "x": -0.385656, "y": -0.0916859 }, + { "x": -0.365099, "y": -0.0793515 }, + { "x": -0.344542, "y": -0.0777069 }, + { "x": -0.324807, "y": -0.0842853 }, + { "x": -0.304249, "y": -0.0933305 }, + { "x": -0.292737, "y": -0.0941528 }, + { "x": -0.287803, "y": -0.0760623 }, + { "x": -0.28287, "y": -0.0587941 }, + { "x": -0.277936, "y": -0.0423482 }, + { "x": -0.269713, "y": -0.02508 }, + { "x": -0.28287, "y": 0.00698951 }, + { "x": -0.281225, "y": 0.0316584 }, + { "x": -0.282047, "y": 0.0571495 }, + { "x": -0.27958, "y": 0.0768846 }, + { "x": -0.27218, "y": 0.104843 }, + { "x": -0.259023, "y": 0.127867 }, + { "x": -0.248333, "y": 0.148424 }, + { "x": -0.234354, "y": 0.165692 }, + { "x": -0.218731, "y": 0.190361 }, + { "x": -0.198995, "y": 0.211741 }, + { "x": -0.18255, "y": 0.235588 }, + { "x": -0.165281, "y": 0.256967 }, + { "x": -0.149658, "y": 0.279169 }, + { "x": -0.125811, "y": 0.294793 }, + { "x": -0.108543, "y": 0.312883 }, + { "x": -0.0888079, "y": 0.338374 }, + { "x": -0.072362, "y": 0.358932 }, + { "x": -0.0567384, "y": 0.382778 }, + { "x": -0.0411148, "y": 0.398402 }, + { "x": -0.027958, "y": 0.412381 }, + { "x": -0.00740066, "y": 0.416493 }, + { "x": -0.00740066, "y": 0.396757 }, + { "x": -0.00822295, "y": 0.377845 }, + { "x": -0.00493377, "y": 0.363866 }, + { "x": -0.0106898, "y": 0.344953 }, + { "x": -0.00740066, "y": 0.32604 }, + { "x": -0.00740066, "y": 0.308772 }, + { "x": -0.00740066, "y": 0.291504 }, + { "x": -0.00328918, "y": 0.27588 }, + { "x": -0.00657836, "y": 0.261901 }, + { "x": -0.00904525, "y": 0.242988 }, + { "x": -0.00246689, "y": 0.224898 }, + { "x": -0.00328918, "y": 0.206807 }, + { "x": 0.00164459, "y": 0.195295 }, + { "x": 0, "y": 0.178027 }, + { "x": -0.00411148, "y": 0.161581 }, + { "x": 0.000822295, "y": 0.143491 }, + { "x": 0.000822295, "y": 0.126222 }, + { "x": -0.00575607, "y": 0.10731 }, + { "x": -0.00411148, "y": 0.0925082 }, + { "x": -0.00493377, "y": 0.0735954 }, + { "x": -0.00740066, "y": 0.0538603 }, + { "x": -0.00575607, "y": 0.033303 }, + { "x": -0.00822295, "y": 0.0102787 }, + { "x": 0.00740066, "y": 0.00370033 }, + { "x": 0.0246689, "y": 0.00452262 }, + { "x": 0.0444039, "y": 0.0086341 }, + { "x": 0.0600275, "y": 0.0135679 }, + { "x": 0.0830518, "y": 0.0201462 }, + { "x": 0.108543, "y": 0.0324807 }, + { "x": 0.130745, "y": 0.0464597 }, + { "x": 0.152125, "y": 0.0637279 }, + { "x": 0.175149, "y": 0.0883967 }, + { "x": 0.194062, "y": 0.108954 }, + { "x": 0.210508, "y": 0.13609 }, + { "x": 0.225309, "y": 0.158292 }, + { "x": 0.239288, "y": 0.176382 }, + { "x": 0.253267, "y": 0.200229 }, + { "x": 0.263134, "y": 0.219964 }, + { "x": 0.276291, "y": 0.238054 }, + { "x": 0.287803, "y": 0.256967 }, + { "x": 0.296849, "y": 0.269302 }, + { "x": 0.31165, "y": 0.278347 }, + { "x": 0.320695, "y": 0.261901 }, + { "x": 0.317406, "y": 0.244633 }, + { "x": 0.310828, "y": 0.232298 }, + { "x": 0.309183, "y": 0.212563 }, + { "x": 0.306716, "y": 0.19694 }, + { "x": 0.30096, "y": 0.179672 }, + { "x": 0.310005, "y": 0.161581 }, + { "x": 0.307538, "y": 0.141846 }, + { "x": 0.300138, "y": 0.123755 }, + { "x": 0.294382, "y": 0.110599 }, + { "x": 0.298493, "y": 0.0957974 }, + { "x": 0.317406, "y": 0.102376 }, + { "x": 0.33303, "y": 0.100731 }, + { "x": 0.348653, "y": 0.0982643 }, + { "x": 0.364277, "y": 0.0982643 }, + { "x": 0.385656, "y": 0.0966197 }, + { "x": 0.404569, "y": 0.0916859 }, + { "x": 0.42266, "y": 0.0867521 }, + { "x": 0.442395, "y": 0.0826407 }, + { "x": 0.458841, "y": 0.0768846 }, + { "x": 0.471997, "y": 0.0719508 }, + { "x": 0.476931, "y": 0.0653725 }, + { "x": 0.470353, "y": 0.0497489 }, + { "x": 0.452262, "y": 0.0439928 }, + { "x": 0.436639, "y": 0.0349475 }, + { "x": 0.423482, "y": 0.0291915 }, + { "x": 0.396346, "y": 0.0226131 }, + { "x": 0.373322, "y": 0.0094564 }, + { "x": 0.352765, "y": 0.000411148 }, + { "x": 0.33303, "y": -0.0086341 }, + { "x": 0.316584, "y": -0.0143902 }, + { "x": 0.301782, "y": -0.0234354 }, + { "x": 0.282047, "y": -0.0308361 }, + { "x": 0.268891, "y": -0.0374144 }, + { "x": 0.249155, "y": -0.0431705 }, + { "x": 0.234354, "y": -0.0489266 }, + { "x": 0.217086, "y": -0.0563272 }, + { "x": 0.20064, "y": -0.0661948 }, + { "x": 0.178438, "y": -0.0777069 }, + { "x": 0.164459, "y": -0.0933305 }, + { "x": 0.154591, "y": -0.110599 }, + { "x": 0.142257, "y": -0.124578 }, + { "x": 0.130745, "y": -0.141024 }, + { "x": 0.120055, "y": -0.155003 }, + { "x": 0.107721, "y": -0.165692 }, + { "x": 0.0945639, "y": -0.177205 }, + { "x": 0.0830518, "y": -0.187894 }, + { "x": 0.0772957, "y": -0.200229 }, + { "x": 0.0822295, "y": -0.209274 }, + { "x": 0.10032, "y": -0.212563 }, + { "x": 0.116766, "y": -0.210919 }, + { "x": 0.136501, "y": -0.211741 }, + { "x": 0.15048, "y": -0.217497 }, + { "x": 0.16117, "y": -0.22572 }, + { "x": 0.174327, "y": -0.230654 }, + { "x": 0.193239, "y": -0.232298 }, + { "x": 0.209685, "y": -0.235588 }, + { "x": 0.216264, "y": -0.242988 }, + { "x": 0.230243, "y": -0.252856 }, + { "x": 0.244222, "y": -0.262723 }, + { "x": 0.26149, "y": -0.274235 }, + { "x": 0.276291, "y": -0.284103 }, + { "x": 0.291093, "y": -0.292326 }, + { "x": 0.302605, "y": -0.303016 }, + { "x": 0.310005, "y": -0.312061 }, + { "x": 0.32234, "y": -0.319462 }, + { "x": 0.335496, "y": -0.328507 }, + { "x": 0.347831, "y": -0.33673 }, + { "x": 0.360988, "y": -0.351531 }, + { "x": 0.3725, "y": -0.363866 }, + { "x": 0.38319, "y": -0.385245 }, + { "x": 0.393879, "y": -0.398402 }, + { "x": 0.407036, "y": -0.412381 }, + { "x": 0.407036, "y": -0.423893 }, + { "x": 0.388946, "y": -0.421426 }, + { "x": 0.374967, "y": -0.41567 }, + { "x": 0.358521, "y": -0.414026 }, + { "x": 0.337141, "y": -0.409092 }, + { "x": 0.320695, "y": -0.40827 }, + { "x": 0.297671, "y": -0.40827 }, + { "x": 0.281225, "y": -0.409914 }, + { "x": 0.256556, "y": -0.406625 }, + { "x": 0.234354, "y": -0.409914 }, + { "x": 0.209685, "y": -0.410736 }, + { "x": 0.185016, "y": -0.411559 }, + { "x": 0.163637, "y": -0.413203 }, + { "x": 0.146369, "y": -0.414848 }, + { "x": 0.129923, "y": -0.416493 }, + { "x": 0.108543, "y": -0.417315 }, + { "x": 0.0920971, "y": -0.418959 }, + { "x": 0.0616721, "y": -0.418959 }, + { "x": 0.0427594, "y": -0.423071 }, + { "x": 0.0164459, "y": -0.420604 }, + { "x": -0.00411148, "y": -0.422249 }, + { "x": -0.027958, "y": -0.419782 }, + { "x": -0.0518046, "y": -0.41567 }, + { "x": -0.0707174, "y": -0.412381 }, + { "x": -0.0879856, "y": -0.40498 }, + { "x": -0.109365, "y": -0.39758 }, + { "x": -0.128278, "y": -0.387712 }, + { "x": -0.145546, "y": -0.380312 }, + { "x": -0.165281, "y": -0.368799 }, + { "x": -0.183372, "y": -0.357287 }, + { "x": -0.203929, "y": -0.34742 }, + { "x": -0.226953, "y": -0.342486 }, + { "x": -0.241755, "y": -0.335085 }, + { "x": -0.260668, "y": -0.327685 }, + { "x": -0.284514, "y": -0.316173 }, + { "x": -0.298493, "y": -0.296437 }, + { "x": -0.309183, "y": -0.280814 }, + { "x": -0.323984, "y": -0.266013 }, + { "x": -0.33303, "y": -0.2545 }, + { "x": -0.346186, "y": -0.238877 }, + { "x": -0.365921, "y": -0.229832 }, + { "x": -0.39059, "y": -0.22572 }, + { "x": -0.408681, "y": -0.231476 }, + { "x": -0.423482, "y": -0.23641 }, + { "x": -0.44075, "y": -0.243811 }, + { "x": -0.458018, "y": -0.249567 } +] diff --git a/src/main/resources/static/data/network.json b/src/main/resources/static/data/NetworkGraph.json similarity index 100% rename from src/main/resources/static/data/network.json rename to src/main/resources/static/data/NetworkGraph.json diff --git a/src/main/resources/static/data/PieChart.json b/src/main/resources/static/data/PieChart.json new file mode 100644 index 00000000..45099eb3 --- /dev/null +++ b/src/main/resources/static/data/PieChart.json @@ -0,0 +1,17 @@ +[ + { + "label": "Label 1", + "value": 140, + "color": "#00618f" + }, + { + "label": "Label 2", + "value": 73, + "color": "#3a4856" + }, + { + "label": "Label 3", + "value": 56, + "color": "#9eadbd" + } +] diff --git a/src/main/resources/static/data/ScrollTable.json b/src/main/resources/static/data/ScrollTable.json new file mode 100644 index 00000000..9b068574 --- /dev/null +++ b/src/main/resources/static/data/ScrollTable.json @@ -0,0 +1,57 @@ +[ + { + "heading1": "Heading 1", + "heading2": "Heading 2", + "heading3": "Heading 3" + }, + { + "heading1": "Cell 4", + "heading2": "Cell 5", + "heading3": "Cell 6" + }, + { + "heading1": "Cell 7", + "heading2": "Cell 8", + "heading3": "Cell 9" + }, + { + "heading1": "Cell 10", + "heading2": "Cell 11", + "heading3": "Cell 12" + }, + { + "heading1": "Cell 13", + "heading2": "Cell 14", + "heading3": "Cell 15" + }, + { + "heading1": "Cell 16", + "heading2": "Cell 17", + "heading3": "Cell 18" + }, + { + "heading1": "Cell 19", + "heading2": "Cell 20", + "heading3": "Cell 21" + }, + { + "heading1": "Cell 22", + "heading2": "Cell 23", + "heading3": "Cell 24" + }, + { + "heading1": "Cell 25", + "heading2": "Cell 26", + "heading3": "Cell 27" + }, + { + "heading1": "Cell 28", + "heading2": "Cell 29", + "heading3": "Cell 30" + }, + { + "heading1": "Cell 31", + "heading2": "Cell 32", + "heading3": "Cell 33" + } +] diff --git a/src/main/resources/static/data/features.geojson b/src/main/resources/static/data/SimpleMap.json similarity index 100% rename from src/main/resources/static/data/features.geojson rename to src/main/resources/static/data/SimpleMap.json diff --git a/src/main/resources/static/data/VoronoiDiagram.json b/src/main/resources/static/data/VoronoiDiagram.json new file mode 100644 index 00000000..9fefc386 --- /dev/null +++ b/src/main/resources/static/data/VoronoiDiagram.json @@ -0,0 +1,137 @@ +[ + { + "label": "Cell 1", + "x": -0.17228836433487893, + "y": 0.0004034504818264395, + "fill": "#FF5400FF", + "stroke": "#800000FF", + "scale": 0.007084618021265124, + "abs": 0.008337255160062937 + }, + { + "label": "Cell 2", + "x": 0.08807457534091101, + "y": 0.288377970457077, + "fill": "#FFAA00FF", + "stroke": "#FF9090FF", + "scale": 0.41333009767848083, + "abs": 0.1593699070893901 + }, + { + "label": "Cell 3", + "x": -0.31771938753358686, + "y": 0.0005067524034529924, + "fill": "#FEFF00FF", + "stroke": "#800000FF", + "scale": 0.018789279751973822, + "abs": 0.012688777059128192 + }, + { + "label": "Cell 4", + "x": -0.43031353820148555, + "y": 0.020645517855882645, + "fill": "#AAFF00FF", + "stroke": "#EF0000FF", + "scale": 0.23818552877079308, + "abs": 0.09425521649525191 + }, + { + "label": "Cell 5", + "x": -0.39947902793006507, + "y": 0.1630820780992508, + "fill": "#54FF00FF", + "stroke": "#3737FFFF", + "scale": 0.6712022835863776, + "abs": 0.25524080792832415 + }, + { + "label": "Cell 6", + "x": -0.20644685197135848, + "y": 0.4445476233959198, + "fill": "#FF5400FF", + "stroke": "#0000C9FF", + "scale": 0.7995153838856561, + "abs": 0.3029446441207315 + }, + { + "label": "Cell 7", + "x": -0.274501020066237, + "y": 0.0338209830224514, + "fill": "#FFAA00FF", + "stroke": "#EF0000FF", + "scale": 0.24382802950165333, + "abs": 0.09635296746497117 + }, + { + "label": "Cell 8", + "x": -0.30629579121375894, + "y": 0.1746092587709427, + "fill": "#FEFF00FF", + "stroke": "#9090FFFF", + "scale": 0.606704243344677, + "abs": 0.23126193173216786 + }, + { + "label": "Cell 9", + "x": -0.09131243803056877, + "y": 0.7978028059005737, + "fill": "#AAFF00FF", + "stroke": "#0B0BFFFF", + "scale": 0.7106488596812152, + "abs": 0.26990613048689727 + }, + { + "label": "Cell 10", + "x": -0.375667030884578, + "y": -0.00032863201340660453, + "fill": "#FF5400FF", + "stroke": "#800000FF", + "scale": 0.014545626429521884, + "abs": 0.011111085128378774 + }, + { + "label": "Cell 11", + "x": -0.29285203078298805, + "y": 0.19254170358181, + "fill": "#FFAA00FF", + "stroke": "#9090FFFF", + "scale": 0.6233699270149123, + "abs": 0.2374578465840815 + }, + { + "label": "Cell 12", + "x": -0.2671300899889575, + "y": 0.08257679641246796, + "fill": "#FEFF00FF", + "stroke": "#FF9090FF", + "scale": 0.38415121252232104, + "abs": 0.14852187400064135 + }, + { + "label": "Cell 13", + "x": -0.015250666764283771, + "y": 0.0021329098381102085, + "fill": "#FF5400FF", + "stroke": "#800000FF", + "scale": 0, + "abs": 0.005703358412311226 + }, + { + "label": "Cell 14", + "x": -0.08866393380969106, + "y": 0.013222741894423962, + "fill": "#FFAA00FF", + "stroke": "#960000FF", + "scale": 0.07675755931322467, + "abs": 0.03424003960438474 + }, + { + "label": "Cell 15", + "x": 0.23463389067618845, + "y": 0.6072919964790344, + "fill": "#FF5400FF", + "stroke": "#00004DFF", + "scale": 1, + "abs": 0.37748017684427615 + } +] diff --git a/src/main/resources/static/data/edges.json b/src/main/resources/static/data/edges.json deleted file mode 100644 index a40e7541..00000000 --- a/src/main/resources/static/data/edges.json +++ /dev/null @@ -1,794 +0,0 @@ -[ - [ - [276.4694937085933, 210.96513455773257], - [231.44104269965737, 212.1801872596352] - ], - [ - [279.8532649869678, 233.57060329542853], - [258.5205158524833, 232.82647918776243] - ], - [ - [258.5205158524833, 232.82647918776243], - [241.73193780364903, 233.5652548110095] - ], - [ - [354.0914634285864, 159.91399739642264], - [344.64352898661446, 156.26693179627298] - ], - [ - [354.0914634285864, 159.91399739642264], - [355.3915585448194, 160.5893335465575] - ], - [ - [298.0951585828476, 253.41146322242082], - [296.1249795167421, 253.9237691377335] - ], - [ - [265.24532194434516, 280.9354551743008], - [262.5295657746439, 275.52695423311343] - ], - [ - [265.24532194434516, 280.9354551743008], - [268.52959214005756, 291.5223584194478] - ], - [ - [265.24532194434516, 280.9354551743008], - [298.60071254578173, 288.5246866833821] - ], - [ - [296.1249795167421, 253.9237691377335], - [251.84475110138965, 255.84281760512903] - ], - [ - [306.5044338898832, 276.4773984066239], - [295.4518779399672, 274.6708656405246] - ], - [ - [295.4518779399672, 274.6708656405246], - [262.5295657746439, 275.52695423311343] - ], - [ - [262.5295657746439, 275.52695423311343], - [257.0016149623103, 268.3043144390161] - ], - [ - [257.0016149623103, 268.3043144390161], - [251.84475110138965, 255.84281760512903] - ], - [ - [257.0016149623103, 268.3043144390161], - [248.7974160356412, 280.5476332931568] - ], - [ - [251.84475110138965, 255.84281760512903], - [250.4395054824601, 253.70056816871642] - ], - [ - [355.3915585448194, 160.5893335465575], - [362.7271876763521, 163.38058961864206] - ], - [ - [342.1290274358434, 155.55973640332618], - [344.64352898661446, 156.26693179627298] - ], - [ - [342.1290274358434, 155.55973640332618], - [326.2469584444125, 148.66787666927289] - ], - [ - [326.2469584444125, 148.66787666927289], - [318.71725758629844, 144.4158418049187] - ], - [ - [295.0487668801159, 132.81409188679584], - [318.71725758629844, 144.4158418049187] - ], - [ - [295.0487668801159, 132.81409188679584], - [270.27799781044007, 118.42217605259567] - ], - [ - [362.7271876763521, 163.38058961864206], - [363.4542635879794, 163.75087253630252] - ], - [ - [363.4542635879794, 163.75087253630252], - [365.74821640103215, 165.2516313899089] - ], - [ - [365.74821640103215, 165.2516313899089], - [370.5318289932407, 167.27611370781707] - ], - [ - [370.5318289932407, 167.27611370781707], - [382.695274511083, 174.39253382785813] - ], - [ - [382.695274511083, 174.39253382785813], - [383.6933439412148, 175.17355957148266] - ], - [ - [383.6933439412148, 175.17355957148266], - [384.31128021867676, 175.4579812619773] - ], - [ - [384.31128021867676, 175.4579812619773], - [399.22012219724786, 184.93445956972047] - ], - [ - [399.22012219724786, 184.93445956972047], - [399.44337301891267, 185.13699113589476] - ], - [ - [399.44337301891267, 185.13699113589476], - [414.9812814645312, 193.67248550284344] - ], - [ - [414.9812814645312, 193.67248550284344], - [418.9159865180896, 194.71940319196102] - ], - [ - [418.9159865180896, 194.71940319196102], - [428.6565810384989, 199.92131198286734] - ], - [ - [303.0569279480815, 312.93992939747466], - [278.90149932292803, 311.98031759010854] - ], - [ - [278.90149932292803, 311.98031759010854], - [283.38973920335974, 323.12917619053445] - ], - [ - [278.90149932292803, 311.98031759010854], - [277.3417775663491, 309.5817435279197] - ], - [ - [428.6565810384989, 199.92131198286734], - [432.7556586672885, 203.09339467829565] - ], - [ - [287.11831209783327, 330.41302506372415], - [283.5713242017622, 323.84335706016253] - ], - [ - [287.11831209783327, 330.41302506372415], - [287.92291446826823, 332.6856735638844] - ], - [ - [432.7556586672885, 203.09339467829565], - [441.3975374800467, 206.67478407764185] - ], - [ - [361.3040048990012, 63.61195904904777], - [339.15871092445195, 66.71700864883806] - ], - [ - [361.3040048990012, 63.61195904904777], - [364.42494915076975, 63.82200603457633] - ], - [ - [287.92291446826823, 332.6856735638844], - [289.1764351271848, 340.5948470485398] - ], - [ - [441.3975374800467, 206.67478407764185], - [447.39747291642686, 210.37431788939247] - ], - [ - [390.1548582411179, 67.53150032675359], - [391.08696064175916, 69.8839770573038] - ], - [ - [390.1548582411179, 67.53150032675359], - [389.37999584841094, 64.2068812820587] - ], - [ - [447.39747291642686, 210.37431788939247], - [451.32642608484963, 211.80833118851157] - ], - [ - [283.38973920335974, 323.12917619053445], - [283.5713242017622, 323.84335706016253] - ], - [ - [277.1977060368536, 309.16964870547844], - [277.3417775663491, 309.5817435279197] - ], - [ - [277.1977060368536, 309.16964870547844], - [268.52959214005756, 291.5223584194478] - ], - [ - [246.1330571447316, 245.2122689356378], - [241.73193780364903, 233.5652548110095] - ], - [ - [246.1330571447316, 245.2122689356378], - [250.4395054824601, 253.70056816871642] - ], - [ - [335.99036460271645, 67.55354638429986], - [318.43944775426735, 75.3669914276231] - ], - [ - [335.99036460271645, 67.55354638429986], - [339.15871092445195, 66.71700864883806] - ], - [ - [241.73193780364903, 233.5652548110095], - [234.5489911050872, 221.09881210635803] - ], - [ - [451.32642608484963, 211.80833118851157], - [461.49828856910466, 217.07679508084726] - ], - [ - [234.5489911050872, 221.09881210635803], - [234.40093114972322, 220.77098047332987] - ], - [ - [234.40093114972322, 220.77098047332987], - [231.44104269965737, 212.1801872596352] - ], - [ - [301.5458412055822, 349.7927024163668], - [295.7460170435482, 350.41479289952423] - ], - [ - [231.44104269965737, 212.1801872596352], - [229.66326782073838, 208.48075523428045] - ], - [ - [463.75836241664285, 215.92923364434284], - [461.49828856910466, 217.07679508084726] - ], - [ - [463.75836241664285, 215.92923364434284], - [469.9771368995175, 213.95285826417518] - ], - [ - [295.7460170435482, 350.41479289952423], - [299.8402481743836, 359.78366242908027] - ], - [ - [295.7460170435482, 350.41479289952423], - [294.00022137633005, 348.99354222535413] - ], - [ - [469.9771368995175, 213.95285826417518], - [484.1425740928738, 211.3218153725464] - ], - [ - [201.96147559195273, 224.99610493724492], - [215.69938872206262, 212.1822546999139] - ], - [ - [215.69938872206262, 212.1822546999139], - [229.66326782073838, 208.48075523428045] - ], - [ - [215.69938872206262, 212.1822546999139], - [202.46892694030836, 213.5047618071658] - ], - [ - [289.1764351271848, 340.5948470485398], - [294.00022137633005, 348.99354222535413] - ], - [ - [461.49828856910466, 217.07679508084726], - [462.30499934136424, 226.23456844885115] - ], - [ - [300.70073122305394, 360.4868882105622], - [304.71815897220546, 368.04666605380226] - ], - [ - [300.70073122305394, 360.4868882105622], - [299.8402481743836, 359.78366242908027] - ], - [ - [229.66326782073838, 208.48075523428045], - [236.3756973040554, 137.48622582642756] - ], - [ - [462.30499934136424, 226.23456844885115], - [462.7936573951168, 227.7606392277182] - ], - [ - [184.71309518569234, 158.48500934411254], - [227.94895328489568, 134.3539906238046] - ], - [ - [395.35004256678917, 62.80840399415247], - [389.37999584841094, 64.2068812820587] - ], - [ - [395.35004256678917, 62.80840399415247], - [405.78175099235244, 61.64085009635142] - ], - [ - [226.72879272195325, 133.79183874518966], - [218.23704745276075, 130.03806205298147] - ], - [ - [226.72879272195325, 133.79183874518966], - [226.95701177950093, 133.88110873819332] - ], - [ - [184.55368250522326, 221.20532621640197], - [191.80280014582843, 217.02499322895773] - ], - [ - [465.48006967305435, 240.40216270930256], - [462.7936573951168, 227.7606392277182] - ], - [ - [465.48006967305435, 240.40216270930256], - [470.2631550740469, 250.30929773975058] - ], - [ - [470.2631550740469, 250.30929773975058], - [476.10496248262695, 257.30649526982756] - ], - [ - [434.57067170175526, 84.36421603192149], - [426.94369686388944, 60.8700238480915] - ], - [ - [364.42494915076975, 63.82200603457633], - [372.42115618470575, 63.36639521142976] - ], - [ - [227.94895328489568, 134.3539906238046], - [236.3756973040554, 137.48622582642756] - ], - [ - [227.94895328489568, 134.3539906238046], - [226.95701177950093, 133.88110873819332] - ], - [ - [202.46892694030836, 213.5047618071658], - [165.30438885078752, 202.7205426418203] - ], - [ - [202.46892694030836, 213.5047618071658], - [191.80280014582843, 217.02499322895773] - ], - [ - [191.80280014582843, 217.02499322895773], - [187.82968299220013, 217.23167084533074] - ], - [ - [218.23704745276075, 130.03806205298147], - [195.55832773322336, 118.6649859817357] - ], - [ - [476.10496248262695, 257.30649526982756], - [478.2777113172204, 260.89941706375345] - ], - [ - [385.68987687102435, 63.86911575158182], - [389.37999584841094, 64.2068812820587] - ], - [ - [385.68987687102435, 63.86911575158182], - [383.42279908412814, 63.99829171383077] - ], - [ - [236.3756973040554, 137.48622582642756], - [248.0767215873927, 129.51210230488735] - ], - [ - [478.2777113172204, 260.89941706375345], - [478.1752152705529, 263.64721959428994] - ], - [ - [478.2777113172204, 260.89941706375345], - [484.14454857266827, 260.7760729964323] - ], - [ - [484.1425740928738, 211.3218153725464], - [491.5850548199624, 211.6154262352176] - ], - [ - [270.27799781044007, 118.42217605259567], - [258.9287117144893, 123.31841182717427] - ], - [ - [270.27799781044007, 118.42217605259567], - [273.3901450234493, 113.54181379158128] - ], - [ - [491.5850548199624, 211.6154262352176], - [505.2025327843412, 214.81294278313706] - ], - [ - [484.14454857266827, 260.7760729964323], - [490.67962875529753, 262.40139958947236] - ], - [ - [372.42115618470575, 63.36639521142976], - [383.42279908412814, 63.99829171383077] - ], - [ - [318.43944775426735, 75.3669914276231], - [315.4090260484865, 76.56369384292763] - ], - [ - [309.6368036127734, 79.5906068574464], - [298.8971141987269, 86.95396607065996] - ], - [ - [309.6368036127734, 79.5906068574464], - [315.4090260484865, 76.56369384292763] - ], - [ - [298.8971141987269, 86.95396607065996], - [287.83582809336195, 96.43358306013239] - ], - [ - [287.83582809336195, 96.43358306013239], - [285.8792142582439, 86.96261764628767] - ], - [ - [287.83582809336195, 96.43358306013239], - [275.56484934225557, 110.60010251391168] - ], - [ - [275.56484934225557, 110.60010251391168], - [273.3901450234493, 113.54181379158128] - ], - [ - [405.78175099235244, 61.64085009635142], - [405.98914770706506, 61.64475306800944] - ], - [ - [478.1752152705529, 263.64721959428994], - [482.17844915471926, 276.93009623205495] - ], - [ - [505.2025327843412, 214.81294278313706], - [506.2894212035501, 215.32701522807386] - ], - [ - [258.9287117144893, 123.31841182717427], - [256.2922918135843, 124.6440420385428] - ], - [ - [133.03452630267716, 120.56885308509187], - [115.55253576755939, 127.77283715496863] - ], - [ - [133.03452630267716, 120.56885308509187], - [133.27524428107301, 120.44170985990107] - ], - [ - [482.17844915471926, 276.93009623205495], - [485.8459590846974, 281.8542222750358] - ], - [ - [485.8459590846974, 281.8542222750358], - [486.5732529761684, 283.82311742621727] - ], - [ - [256.2922918135843, 124.6440420385428], - [248.0767215873927, 129.51210230488735] - ], - [ - [405.98914770706506, 61.64475306800944], - [418.04071315431014, 60.635435156741686] - ], - [ - [418.04071315431014, 60.635435156741686], - [423.4945531986324, 61.09630169067084] - ], - [ - [486.5732529761684, 283.82311742621727], - [488.3334394658448, 285.68600140764266] - ], - [ - [488.3334394658448, 285.68600140764266], - [491.53350845842067, 291.5913973323966] - ], - [ - [423.4945531986324, 61.09630169067084], - [426.94369686388944, 60.8700238480915] - ], - [ - [426.94369686388944, 60.8700238480915], - [433.13359118983544, 59.22902276649376] - ], - [ - [506.2894212035501, 215.32701522807386], - [509.3902187069389, 215.9511658560672] - ], - [ - [509.3902187069389, 215.9511658560672], - [514.9985045981061, 215.95116585606692] - ], - [ - [115.55253576755939, 127.77283715496863], - [110.11952421689135, 128.84739082632132] - ], - [ - [491.53350845842067, 291.5913973323966], - [491.9263114647667, 294.96213456391547] - ], - [ - [433.13359118983544, 59.22902276649376], - [433.44285837951077, 59.21167583541018] - ], - [ - [195.55832773322336, 118.6649859817357], - [194.1955893698774, 112.68844342486503] - ], - [ - [195.55832773322336, 118.6649859817357], - [178.94067714608974, 113.63602906448565] - ], - [ - [491.9263114647667, 294.96213456391547], - [496.9066746069053, 301.610201354817] - ], - [ - [496.9066746069053, 301.610201354817], - [497.8528005277029, 302.1213681599282] - ], - [ - [433.44285837951077, 59.21167583541018], - [443.04882399115183, 57.422796524579] - ], - [ - [443.04882399115183, 57.422796524579], - [448.10594339483487, 55.735178221114225] - ], - [ - [514.9985045981061, 215.95116585606692], - [518.1476829204133, 216.8371659561265] - ], - [ - [518.1476829204133, 216.8371659561265], - [528.1394461615695, 217.43765721634637] - ], - [ - [497.8528005277029, 302.1213681599282], - [500.47261855649384, 305.2436031548176] - ], - [ - [448.10594339483487, 55.735178221114225], - [454.3421431720128, 55.1372429477216] - ], - [ - [102.67156501946316, 131.25430325438955], - [105.6370769481479, 130.65826730368994] - ], - [ - [102.67156501946316, 131.25430325438955], - [100.71620949747984, 131.26957904138072] - ], - [ - [178.94067714608974, 113.63602906448565], - [173.83764027333171, 112.63383085357049] - ], - [ - [500.47261855649384, 305.2436031548176], - [500.7728814233292, 306.6272110513359] - ], - [ - [454.3421431720128, 55.1372429477216], - [459.9699751529196, 53.52748495785983] - ], - [ - [459.9699751529196, 53.52748495785983], - [465.23506136030335, 53.246853909185894] - ], - [ - [528.1394461615695, 217.43765721634637], - [529.9639202468364, 218.1624667767026] - ], - [ - [173.83764027333171, 112.63383085357049], - [159.78812757526987, 88.3115532152507] - ], - [ - [173.83764027333171, 112.63383085357049], - [170.45022006025582, 112.77168320147] - ], - [ - [529.9639202468364, 218.1624667767026], - [540.1579194932982, 219.647334740655] - ], - [ - [105.6370769481479, 130.65826730368994], - [110.11952421689135, 128.84739082632132] - ], - [ - [170.45022006025582, 112.77168320147], - [147.99525551117816, 115.60115178319484] - ], - [ - [465.23506136030335, 53.246853909185894], - [476.6124260148762, 50.62595058999021] - ], - [ - [476.6124260148762, 50.62595058999021], - [479.4713335954088, 49.39049094059168] - ], - [ - [147.99525551117816, 115.60115178319484], - [143.47071375576357, 116.7776570745938] - ], - [ - [479.4713335954088, 49.39049094059168], - [479.9534039501394, 49.332633018564785] - ], - [ - [143.47071375576357, 116.7776570745938], - [133.27524428107301, 120.44170985990107] - ], - [ - [540.1579194932982, 219.647334740655], - [540.5763694335384, 219.56265099487962] - ], - [ - [479.9534039501394, 49.332633018564785], - [483.8257543111303, 48.22499198797036] - ], - [ - [540.5763694335384, 219.56265099487962], - [550.614276435025, 221.63630688804375] - ], - [ - [100.71620949747984, 131.26957904138072], - [94.70169833715312, 130.0407679953584] - ], - [ - [483.8257543111303, 48.22499198797036], - [487.9612590131488, 46.48546394919099] - ], - [ - [550.614276435025, 221.63630688804375], - [560.0765675479196, 220.0252688156484] - ], - [ - [487.9612590131488, 46.48546394919099], - [497.82892376422654, 44.67625428332226] - ], - [ - [497.82892376422654, 44.67625428332226], - [501.4405397899933, 43.241467734518004] - ], - [ - [94.70169833715312, 130.0407679953584], - [89.95650085513392, 128.0822549997453] - ], - [ - [501.4405397899933, 43.241467734518004], - [505.30442121108626, 43.13910924411935] - ], - [ - [560.0765675479196, 220.0252688156484], - [561.8944393758946, 220.8120769637511] - ], - [ - [561.8944393758946, 220.8120769637511], - [571.0135588111234, 220.9983224167926] - ], - [ - [505.30442121108626, 43.13910924411935], - [513.874901571454, 40.864635057356146] - ], - [ - [89.95650085513392, 128.0822549997453], - [89.52599676040396, 128.02667060507963] - ], - [ - [89.52599676040396, 128.02667060507963], - [82.04398409801168, 125.41893044476957] - ], - [ - [571.0135588111234, 220.9983224167926], - [574.4003384425555, 222.30533867257213] - ], - [ - [513.874901571454, 40.864635057356146], - [513.9293281107559, 40.83510978685411] - ], - [ - [82.01314328513119, 125.4295769782849], - [82.04398409801168, 125.41893044476957] - ], - [ - [574.4003384425555, 222.30533867257213], - [579.7180228218145, 221.3874856352264] - ], - [ - [513.9293281107559, 40.83510978685411], - [519.5105273485341, 39.65592751997516] - ], - [ - [82.04398409801168, 125.41893044476957], - [70.74092265859326, 114.02365560579803] - ], - [ - [70.74092265859326, 114.02365560579803], - [61.96674041485678, 110.14841328676359] - ], - [ - [519.5105273485341, 39.65592751997516], - [526.761562854262, 35.35965597691259] - ], - [ - [579.7180228218145, 221.3874856352264], - [585.0931872927339, 223.35011030992843] - ], - [ - [526.761562854262, 35.35965597691259], - [528.3894706676772, 35.267750804430094] - ], - [ - [585.0931872927339, 223.35011030992843], - [592.0875281344113, 221.34930280665594] - ], - [ - [61.96674041485678, 110.14841328676359], - [61.22713383848892, 109.38684685409505] - ], - [ - [61.22713383848892, 109.38684685409505], - [52.83602015686794, 105.52960625068711] - ], - [ - [528.3894706676772, 35.267750804430094], - [534.7482093164325, 32.73334693987367] - ], - [ - [592.0875281344113, 221.34930280665594], - [594.325248755682, 222.70699676507846] - ], - [ - [594.325248755682, 222.70699676507846], - [598.1739972776145, 222.29941243781875] - ], - [ - [534.7482093164325, 32.73334693987367], - [536.7736997711603, 30.25043031681742] - ], - [ - [52.83602015686794, 105.52960625068711], - [51.57407983372714, 104.15458046484554] - ], - [ - [51.57407983372714, 104.15458046484554], - [45.22358035225297, 102.19575829610763] - ], - [ - [536.7736997711603, 30.25043031681742], - [545.5341845713319, 26.8642185578681] - ], - [ - [545.5341845713319, 26.8642185578681], - [546.7679455562375, 24.931748732169883] - ], - [ - [45.22358035225297, 102.19575829610763], - [42.264677791160786, 99.17308008311863] - ], - [ - [546.7679455562375, 24.931748732169883], - [554.3366724317648, 22.612542111221813] - ], - [ - [42.264677791160786, 99.17308008311863], - [37.06168960696543, 98.46423652532214] - ], - [ - [554.3366724317648, 22.612542111221813], - [555.1968349371222, 20.15219278845622] - ], - [ - [37.06168960696543, 98.46423652532214], - [32.857857568260606, 93.31094352459202] - ] -] diff --git a/src/main/resources/static/data/table.json b/src/main/resources/static/data/table.json deleted file mode 100644 index 29890eef..00000000 --- a/src/main/resources/static/data/table.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - ["Heading 1", "Heading 2", "Heading 3"], - ["Cell 4", "Cell 5", "Cell 6"], - ["Cell 7", "Cell 8", "Cell 9"], - ["Cell 10", "Cell 11", "Cell 12"], - ["Cell 13", "Cell 14", "Cell 15"], - ["Cell 16", "Cell 17", "Cell 18"], - ["Cell 19", "Cell 20", "Cell 21"], - ["Cell 22", "Cell 23", "Cell 24"], - ["Cell 25", "Cell 26", "Cell 27"], - ["Cell 28", "Cell 29", "Cell 30"], - ["Cell 31", "Cell 32", "Cell 33"] -] diff --git a/src/main/resources/static/js/api/HTTPClient.js b/src/main/resources/static/js/api/HTTPClient.js index b10f5257..d7f25d8b 100644 --- a/src/main/resources/static/js/api/HTTPClient.js +++ b/src/main/resources/static/js/api/HTTPClient.js @@ -3,7 +3,7 @@ export default class HTTPClient { this.baseUrl = baseUrl; } - async request(path, options = {}) { + async request(path, options = {}, responseType = "json") { const response = await fetch(this.baseUrl + path, { headers: { "Content-Type": "application/json", @@ -13,34 +13,40 @@ export default class HTTPClient { }); if (!response.ok) { - const error = await response.text(); - throw new Error(`${response.status}: ${error}`); + const error = await response.json(); + throw new Error(`${error.path} ${response.status}: ${error.error}`); } - return response.json(); + const parsers = { + json: () => response.json(), + text: () => response.text(), + blob: () => response.blob(), + }; + + return parsers[responseType](); } - get(path) { - return this.request(path); + get(path, responseType) { + return this.request(path, {}, responseType); } - post(path, body) { - return this.request(path, { - method: "POST", - body: JSON.stringify(body), - }); + post(path, body, responseType) { + return this.request( + path, + { method: "POST", body: JSON.stringify(body) }, + responseType, + ); } - put(path, body) { - return this.request(path, { - method: "PUT", - body: JSON.stringify(body), - }); + put(path, body, responseType) { + return this.request( + path, + { method: "PUT", body: JSON.stringify(body) }, + responseType, + ); } - delete(path) { - return this.request(path, { - method: "DELETE", - }); + delete(path, responseType) { + return this.request(path, { method: "DELETE" }, responseType); } } diff --git a/src/main/resources/static/js/api/convertions.api.js b/src/main/resources/static/js/api/convertions.api.js index fb4578f7..6bd365da 100644 --- a/src/main/resources/static/js/api/convertions.api.js +++ b/src/main/resources/static/js/api/convertions.api.js @@ -3,3 +3,17 @@ import { api } from "./clients.js"; export async function getTikz(type, svg, data, meta) { return await api.post("/convertions/tikz", { type, svg, data, meta }); } + +export async function getCsv(type, data, meta) { + return await api.post("/convertions/csv", { type, data, meta }); +} + +export async function createZip(blobs, type) { + const formData = new FormData(); + blobs.forEach((blob, i) => formData.append("files", blob, `${i}.${type}`)); + + return await fetch(api.baseUrl + "/convertions/zip", { + method: "POST", + body: formData, + }).then((response) => response.blob()); +} diff --git a/src/main/resources/static/js/api/data.api.js b/src/main/resources/static/js/api/data.api.js index 475209ba..3fbb4787 100644 --- a/src/main/resources/static/js/api/data.api.js +++ b/src/main/resources/static/js/api/data.api.js @@ -1,8 +1,31 @@ import { api } from "./clients.js"; -export async function getData(pipelineId, generatorId, chartType, filter) { - return await api.post( - `/data?pipelineId=${pipelineId}&generatorId=${generatorId}&chartType=${chartType}`, - filter, - ); +export async function getData( + pipelineId, + generatorId, + chartType, + page, + size, + filter, +) { + return await api + .post( + `/data?pipelineId=${pipelineId}&generatorId=${generatorId}&chartType=${chartType}&page=${page}&size=${size}`, + filter, + ) + .then(async ({ data, meta }) => { + if (meta.total === 0) { + return { + data: [await d3.json(`/data/${chartType}.json`)], + meta: { total: 1, ids: ["1"] }, + }; + } + return { data, meta }; + }) + .catch(async () => { + return { + data: [await d3.json(`/data/${chartType}.json`)], + meta: { total: 1, ids: ["1"] }, + }; + }); } diff --git a/src/main/resources/static/js/api/pipelines.v2.api.js b/src/main/resources/static/js/api/pipelines.v2.api.js new file mode 100644 index 00000000..2bf8af04 --- /dev/null +++ b/src/main/resources/static/js/api/pipelines.v2.api.js @@ -0,0 +1,33 @@ +import { api } from "./clients.js"; + +export async function createSource(pipelineId, config) { + return await api.post(`/v2/pipelines/${pipelineId}/sources/${config.id}`, config); +} + +export async function updateSource(pipelineId, config) { + return await api.put(`/v2/pipelines/${pipelineId}/sources/${config.id}`, config); +} + +export async function deleteSource(pipelineId, config) { + return await api.delete(`/v2/pipelines/${pipelineId}/sources/${config.id}`); +} + +export async function createGenerator(pipelineId, config) { + return await api.post(`/v2/pipelines/${pipelineId}/generators/${config.id}`, config); +} + +export async function updateGenerator(pipelineId, config) { + return await api.put(`/v2/pipelines/${pipelineId}/generators/${config.id}`, config); +} + +export async function deleteGenerator(pipelineId, config) { + return await api.delete(`/v2/pipelines/${pipelineId}/generators/${config.id}`); +} + +export async function promotePipeline(pipelineId, name, widgets) { + return await api.post(`/v2/pipelines/${pipelineId}/promote`, { name, widgets }); +} + +export async function deletePipeline(pipelineId) { + return await api.delete(`/v2/pipelines/${pipelineId}`); +} diff --git a/src/main/resources/static/js/pages/editor/Editor.js b/src/main/resources/static/js/pages/editor/Editor.js index 75575cb8..19dc6530 100644 --- a/src/main/resources/static/js/pages/editor/Editor.js +++ b/src/main/resources/static/js/pages/editor/Editor.js @@ -1,16 +1,12 @@ import accordions from "../../shared/modules/accordions.js"; -import { - identifierValid, - widgetsValid, - sourcesValid, -} from "./utils/editorValidations.js"; +import { widgetsValid, sourcesValid } from "./utils/editorValidations.js"; import state from "./utils/editorState.js"; import { createSource, createWidget, loadSources, } from "./utils/editorActions.js"; -import { debounce } from "../../shared/modules/utils.js"; +import { debounce, randomId } from "../../shared/modules/utils.js"; import { createPipeline, getPipelines, @@ -21,6 +17,7 @@ import Source from "./configs/Source.js"; export default class Editor { constructor() { + this.showWarning = true; this.widgetDefaults = Object.values(widgets).map( (Widget) => Widget.defaultConfig, ); @@ -32,9 +29,20 @@ export default class Editor { this.initAvailableWidgets(); this.initGrid(); + const safeArray = (value) => (Array.isArray(value) ? value : []); + // Load existing data - loadSources(config.sources || [], config.generators || []); - state.grid.load(config.widgets || []); + state.id = config.id || randomId("pipeline"); + loadSources(safeArray(config.sources), safeArray(config.generators)); + state.grid.load(safeArray(config.widgets)); + + // Warn on leaving + window.addEventListener("beforeunload", (event) => { + if (this.showWarning) { + event.preventDefault(); + return ""; + } + }); // Replace whitespaces in the id with dashes const input = document.querySelector("#identifier-input"); @@ -50,17 +58,21 @@ export default class Editor { .addEventListener("click", () => { const controller = createSource(Source.defaultConfig); - container.prepend(controller.root); + // TODO: api.createSource(state.id, controller.item); + container.append(controller.root); controller.init(); }); document.querySelector("#discard-button").addEventListener("click", () => { state.modal.confirm("Discard Changes", "Are you sure?", async () => { - const id = input.value; const pipelines = await getPipelines(); + this.showWarning = false; + + // Delete temp pipeline + // TODO: api.deletePipeline(state.id); - if (pipelines.includes(id)) { - window.open("/view/" + id, "_self"); + if (pipelines.find((p) => p.id === state.id)) { + window.open("/view/" + state.id, "_self"); } else { window.open("/", "_self"); } @@ -117,7 +129,8 @@ export default class Editor { } initAvailableWidgets() { - const container = document.querySelector(".dv-available-widgets-container"); + const staticCont = document.querySelector(".dv-static-widgets-container"); + const dynamicCont = document.querySelector(".dv-dynamic-widgets-container"); const template = document.querySelector("#available-widget-template"); this.widgetDefaults.forEach((widget) => { @@ -128,36 +141,62 @@ export default class Editor { element.querySelector("span").textContent = widget.title; delete widget.icon; - container.append(element); + if (widget.type.startsWith("Static")) { + staticCont.append(element); + } else { + dynamicCont.append(element); + } }); } - async validate(id) { + async validate(name) { const pipelines = await getPipelines(); const config = { - id: id, + id: state.id, + name: name, sources: state.sources, generators: state.generators, widgets: state.grid.save(false), }; - const ok = - identifierValid(config) && widgetsValid(config) && sourcesValid(config); + const ok = widgetsValid(config) && sourcesValid(config); - if (ok && pipelines.includes(config.id)) { + if (ok && pipelines.find((p) => p.id === config.id)) { state.modal.confirm( - `Overwrite "${config.id}"`, + `Overwrite "${config.name}"`, "This pipeline already exists. Do you want to overwrite it?", - async () => { + () => { state.modal.loading("Updating pipeline, please wait..."); - await updatePipeline(config); - window.open("/view/" + config.id, "_self"); + + updatePipeline(config) + .then(() => { + this.showWarning = false; + window.open("/view/" + config.id, "_self"); + }) + .catch(() => { + state.modal.alert( + "Internal Server Error", + "An error occurred while updating the pipeline.", + ); + }); }, ); } else if (ok) { state.modal.loading("Creating pipeline, please wait..."); - await createPipeline(config); - window.open("/view/" + config.id, "_self"); + + // Promote temp pipeline + // TODO: api.promotePipeline(state.id, config.name, config.widgets); + createPipeline(config) + .then(() => { + this.showWarning = false; + window.open("/view/" + config.id, "_self"); + }) + .catch(() => { + state.modal.alert( + "Internal Server Error", + "An error occurred while creating the pipeline.", + ); + }); } } } diff --git a/src/main/resources/static/js/pages/editor/configs/CategoryNumber.js b/src/main/resources/static/js/pages/editor/configs/CategoryNumber.js index 0fca9af4..cc22730f 100644 --- a/src/main/resources/static/js/pages/editor/configs/CategoryNumber.js +++ b/src/main/resources/static/js/pages/editor/configs/CategoryNumber.js @@ -2,11 +2,15 @@ export default class CategoryNumber { static token = "CN"; static description = ` A generator that maps categories/labels to a numeric value and an associated color. -
Compatible with: Bar Chart, Pie Chart`; +
Compatible with: Bar Chart, Pie Chart, Table`; static defaultConfig = { name: "New CategoryNumber", type: "CategoryNumber", - settings: {}, + generatorGroup: false, + settings: { + categoriesWhitelist: [], + categoriesBlacklist: [], + }, extends: [], }; static formConfig = { @@ -14,5 +18,25 @@ export default class CategoryNumber { type: "text", label: "Name", }, + "settings.categoriesWhitelist": { + type: "json", + label: "Categories whitelist (json)", + options: { + rows: 2, + validator: (json) => + Array.isArray(json) && json.every((item) => typeof item === "string"), + message: "Invalid json. Only an array of strings is allowed.", + }, + }, + "settings.categoriesBlacklist": { + type: "json", + label: "Categories blacklist (json)", + options: { + rows: 2, + validator: (json) => + Array.isArray(json) && json.every((item) => typeof item === "string"), + message: "Invalid json. Only an array of strings is allowed.", + }, + }, }; } diff --git a/src/main/resources/static/js/pages/editor/configs/MapCoordinates.js b/src/main/resources/static/js/pages/editor/configs/MapCoordinates.js index 129cb6aa..0c901e04 100644 --- a/src/main/resources/static/js/pages/editor/configs/MapCoordinates.js +++ b/src/main/resources/static/js/pages/editor/configs/MapCoordinates.js @@ -2,11 +2,15 @@ export default class MapCoordinates { static token = "MC"; static description = ` A generator for storing labeled and color-coded positions within a map or spatial environment. -
Compatible with: Line Chart`; +
Compatible with: Line Chart, Simple Map, Network Graph, Table`; static defaultConfig = { name: "New MapCoordinates", type: "MapCoordinates", - settings: {}, + generatorGroup: false, + settings: { + keysMap: {}, + fixedKeys: {}, + }, extends: [], }; static formConfig = { @@ -14,5 +18,25 @@ export default class MapCoordinates { type: "text", label: "Name", }, + generatorGroup: { + type: "switch", + label: "Generator group", + }, + "settings.keysMap": { + type: "json", + label: "Keys mapping (json)", + options: { + rows: 5, + message: "Invalid json mapping.", + }, + }, + "settings.fixedKeys": { + type: "json", + label: "Fixed keys (json)", + options: { + rows: 5, + message: "Invalid json mapping.", + }, + }, }; } diff --git a/src/main/resources/static/js/pages/editor/configs/Source.js b/src/main/resources/static/js/pages/editor/configs/Source.js index 52f442d8..da801cf1 100644 --- a/src/main/resources/static/js/pages/editor/configs/Source.js +++ b/src/main/resources/static/js/pages/editor/configs/Source.js @@ -3,17 +3,40 @@ import { getAnnotations } from "../../../api/annotations.api.js"; export default class Source { static defaultConfig = { uri: "", - settings: {}, + settings: { + sourceFilesWhitelist: [], + sourceFilesBlacklist: [], + }, }; static formConfig = { uri: { type: "searchselect", label: "Annotation type", options: { - header: ["Annotation", "#"], + headers: ["Annotation", "#"], keys: ["annotation", "rowCount"], getData: getAnnotations, }, }, + "settings.sourceFilesWhitelist": { + type: "json", + label: "Source files whitelist (json)", + options: { + rows: 2, + validator: (json) => + Array.isArray(json) && json.every((item) => typeof item === "string"), + message: "Invalid json. Only an array of strings is allowed.", + }, + }, + "settings.sourceFilesBlacklist": { + type: "json", + label: "Source files blacklist (json)", + options: { + rows: 2, + validator: (json) => + Array.isArray(json) && json.every((item) => typeof item === "string"), + message: "Invalid json. Only an array of strings is allowed.", + }, + }, }; } diff --git a/src/main/resources/static/js/pages/editor/configs/TextFormatting.js b/src/main/resources/static/js/pages/editor/configs/TextFormatting.js index 778d1304..b9bad755 100644 --- a/src/main/resources/static/js/pages/editor/configs/TextFormatting.js +++ b/src/main/resources/static/js/pages/editor/configs/TextFormatting.js @@ -4,10 +4,11 @@ export default class TextFormatting { static token = "TF"; static description = ` A generator for storing text, including various types of formatting and colorization. -
Compatible with: Highlight Text`; +
Compatible with: Highlight Text`; static defaultConfig = { name: "New TextFormatting", type: "TextFormatting", + generatorGroup: false, settings: { style: "underline", sofaFile: "", @@ -28,7 +29,7 @@ export default class TextFormatting { type: "searchselect", label: "XML File", options: { - header: ["File", ""], + headers: ["File", ""], getData: getFiles, }, }, diff --git a/src/main/resources/static/js/pages/editor/controller/GeneratorController.js b/src/main/resources/static/js/pages/editor/controller/GeneratorController.js index 4be46486..e450444f 100644 --- a/src/main/resources/static/js/pages/editor/controller/GeneratorController.js +++ b/src/main/resources/static/js/pages/editor/controller/GeneratorController.js @@ -35,7 +35,7 @@ export default class GeneratorController { type: "multiselect", label: "Extends (optional)", options: () => - getGeneratorOptions(this.item.type).filter( + getGeneratorOptions([this.item.type]).filter( (option) => option.value !== this.item.id, ), }, @@ -49,14 +49,23 @@ export default class GeneratorController { const buttons = this.root.querySelectorAll("button"); buttons[0].addEventListener("click", () => { - const { name, settings, extends: ext } = this.item; + const { name, generatorGroup, settings, extends: ext } = this.item; + const defaultSettings = Generator.defaultConfig.settings; builder.buildForm( - { name, settings, extends: ext }, - ({ name, settings, extends: ext }) => { + { + name, + generatorGroup, + settings: { ...defaultSettings, ...settings }, + extends: ext, + }, + ({ name, generatorGroup, settings, extends: ext }) => { this.setName(name); + this.item.generatorGroup = generatorGroup; this.item.settings = settings; this.item.extends = ext; + + // TODO: api.updateGenerator(state.id, this.item); + rerender connected widgets }, ); }); @@ -64,6 +73,9 @@ export default class GeneratorController { // Remove generator from the dom this.root.remove(); + // Remove generator from the backend + // TODO: api.deleteGenerator(state.id, this.item); + rerender connected widgets + // Remove generator from the state list removeGenerator(this.item); }); diff --git a/src/main/resources/static/js/pages/editor/controller/SourceController.js b/src/main/resources/static/js/pages/editor/controller/SourceController.js index 8932ecbf..d1caf85e 100644 --- a/src/main/resources/static/js/pages/editor/controller/SourceController.js +++ b/src/main/resources/static/js/pages/editor/controller/SourceController.js @@ -52,7 +52,7 @@ export default class SourceController { "button", { className: "dv-btn dv-generator-option" }, [ - createElement("span", { className: "dv-generator-card-title" }, [ + createElement("span", { className: "dv-generator-card-header" }, [ createElement("div", { className: "dv-generator-card-token", textContent: Generator.token, @@ -75,18 +75,27 @@ export default class SourceController { // Initialize buttons buttons[1].addEventListener("click", () => { - const { uri } = this.item; + const { uri, settings } = this.item; + const defaultSettings = Source.defaultConfig.settings; - builder.buildForm({ uri }, ({ uri }) => { - this.item.uri = uri; - }); + builder.buildForm( + { uri, settings: { ...defaultSettings, ...settings } }, + ({ uri, settings }) => { + this.item.uri = uri; + this.item.settings = settings; + // TODO: api.updateSource(state.id, this.item); + rerender connected widgets + }, + ); }); buttons[2].addEventListener("click", () => { - // Remove generator from the dom + // Remove source from the dom this.root.remove(); - // Remove generator from the state list + // Remove source from the backend + // TODO: api.deleteSource(state.id, this.item); + remove connected generators + + // Remove source from the state list removeSource(this.item); }); } @@ -94,6 +103,7 @@ export default class SourceController { appendGenerator(container, config) { const controller = createGenerator(config, this.item.id); + // TODO: api.createGenerator(state.id, config); container.append(controller.root); controller.init(); } diff --git a/src/main/resources/static/js/pages/editor/controller/WidgetController.js b/src/main/resources/static/js/pages/editor/controller/WidgetController.js index 870abbca..a1547be9 100644 --- a/src/main/resources/static/js/pages/editor/controller/WidgetController.js +++ b/src/main/resources/static/js/pages/editor/controller/WidgetController.js @@ -6,7 +6,9 @@ import { createTemplateElement } from "../../../shared/modules/utils.js"; export default class WidgetController { constructor(item) { this.root = createTemplateElement( - item.src ? "#static-widget-template" : "#chart-widget-template", + item.type.startsWith("Static") + ? "#static-widget-template" + : "#chart-widget-template", ); this.item = item; this.widget = null; @@ -17,6 +19,11 @@ export default class WidgetController { if (this.widget.setTitle) this.widget.setTitle(title); } + setGenerator(generator) { + this.item.generator = generator; + this.widget.rerender(true); + } + setSrc(src) { this.item.src = src; this.widget.src = src; @@ -39,15 +46,16 @@ export default class WidgetController { ); const buttons = this.root.querySelectorAll("button"); - this.widget = new Widget(this.root, this.item); - this.widget.render(Widget.previewData || this.item.src); + this.widget = new Widget(this.root, { pipeline: state.id, ...this.item }); + this.widget.rerender(true); buttons[0].addEventListener("click", () => { const { title, generator, src, options } = this.item; + const defaultOptions = Widget.defaultConfig.options; const config = generator - ? { title, generator, options } - : { title, src, options }; + ? { title, generator, options: { ...defaultOptions, ...options } } + : { title, src, options: { ...defaultOptions, ...options } }; builder.buildForm(config, ({ title, generator, src, options }) => { this.setTitle(title); @@ -58,7 +66,7 @@ export default class WidgetController { } this.setOptions(options); - this.widget.render(Widget.previewData || this.item.src); + this.widget.rerender(true); }); }); buttons[1].addEventListener("click", () => { diff --git a/src/main/resources/static/js/pages/editor/utils/editorActions.js b/src/main/resources/static/js/pages/editor/utils/editorActions.js index d75bb247..32e9cef1 100644 --- a/src/main/resources/static/js/pages/editor/utils/editorActions.js +++ b/src/main/resources/static/js/pages/editor/utils/editorActions.js @@ -10,8 +10,8 @@ export function loadSources(sources, generators) { for (const config of sources) { const controller = createSource(config); - container.prepend(controller.root); - controller.init(generators); + container.append(controller.root); + controller.init(generators.filter((gen) => gen.source === config.id)); } } @@ -53,8 +53,10 @@ export function createWidget(item) { return new WidgetController(item); } -export function getGeneratorOptions(type) { - const configs = state.generators.filter((config) => config.type === type); +export function getGeneratorOptions(types) { + const configs = types + ? state.generators.filter((config) => types.includes(config.type)) + : state.generators; return configs.map((generator) => { return { label: generator.name, value: generator.id }; diff --git a/src/main/resources/static/js/pages/editor/utils/editorState.js b/src/main/resources/static/js/pages/editor/utils/editorState.js index a6992afb..54963d55 100644 --- a/src/main/resources/static/js/pages/editor/utils/editorState.js +++ b/src/main/resources/static/js/pages/editor/utils/editorState.js @@ -2,6 +2,7 @@ import Modal from "../../../shared/classes/Modal.js"; export default { modal: new Modal(), + id: "", sources: [], generators: [], grid: null, diff --git a/src/main/resources/static/js/pages/editor/utils/editorValidations.js b/src/main/resources/static/js/pages/editor/utils/editorValidations.js index 9a3f870d..2313be41 100644 --- a/src/main/resources/static/js/pages/editor/utils/editorValidations.js +++ b/src/main/resources/static/js/pages/editor/utils/editorValidations.js @@ -1,19 +1,5 @@ import state from "./editorState.js"; -export function identifierValid(config) { - // Check for empty id - const valid = config.id.trim() !== ""; - - if (!valid) { - state.modal.alert( - "Missing Identifier", - "Please provide an identifier for the pipeline.", - ); - } - - return valid; -} - export function widgetsValid(config) { if (config.widgets.length > 0) { // Check for empty or removed generators diff --git a/src/main/resources/static/js/pages/index/Menu.js b/src/main/resources/static/js/pages/index/Menu.js index 524d4f5a..bd3729c9 100644 --- a/src/main/resources/static/js/pages/index/Menu.js +++ b/src/main/resources/static/js/pages/index/Menu.js @@ -13,9 +13,9 @@ export default class Menu { document.querySelectorAll("[data-dv-toggle='modal']").forEach((node) => { node.addEventListener("click", () => { this.modal.confirm( - "Delete " + node.dataset.pipeline, + "Delete " + node.dataset.name, "Do you want to delete this pipeline?", - () => this.removePipeline(node.dataset.pipeline), + () => this.removePipeline(node.dataset.id), ); }); }); diff --git a/src/main/resources/static/js/pages/view/View.js b/src/main/resources/static/js/pages/view/View.js index c1bbafe0..1d93379f 100644 --- a/src/main/resources/static/js/pages/view/View.js +++ b/src/main/resources/static/js/pages/view/View.js @@ -1,10 +1,10 @@ import getter from "../../widgets/widgets.js"; import sidepanels from "../../shared/modules/sidepanels.js"; import accordions from "../../shared/modules/accordions.js"; -import dropdowns from "../../shared/modules/dropdowns.js"; -import ChartGPT from "../../shared/classes/ChartGPT.js"; +import ChatBot from "../../shared/classes/ChatBot.js"; import state from "./utils/viewState.js"; import { createTemplateElement } from "../../shared/modules/utils.js"; +import { instruction } from "../../shared/modules/instruction.js"; export default class View { constructor(pipeline) { @@ -19,10 +19,11 @@ export default class View { state.corpusFilter.init(); sidepanels.init(); accordions.init(); - dropdowns.init(); - const chatBot = new ChartGPT("You are an assistant called ChartGPT."); - chatBot.init(); + if (document.querySelector(".dv-chat-bot")) { + const chatbot = new ChatBot(instruction); + chatbot.init(); + } } initSwitcher() { @@ -47,7 +48,7 @@ export default class View { resetButton.classList.add("dv-hidden"); for (const chart of state.charts) { - chart.fetch().then((data) => chart.render(data)); + chart.rerender(true); } }); @@ -56,7 +57,7 @@ export default class View { resetButton.classList.remove("dv-hidden"); for (const chart of state.charts) { - chart.fetch().then((data) => chart.render(data)); + chart.rerender(true); } }); } @@ -85,7 +86,7 @@ export default class View { const widget = new Widget(root, { pipeline: this.pipeline, ...item }); widget.init(); - if (!item.src) { + if (!item.type.startsWith("Static")) { state.charts.push(widget); } }); diff --git a/src/main/resources/static/js/pages/view/toolbar/ExportHandler.js b/src/main/resources/static/js/pages/view/toolbar/ExportHandler.js index 7dcaa74e..dadfe14f 100644 --- a/src/main/resources/static/js/pages/view/toolbar/ExportHandler.js +++ b/src/main/resources/static/js/pages/view/toolbar/ExportHandler.js @@ -1,189 +1,178 @@ -import { getTikz } from "../../../api/convertions.api.js"; -import { applyStyles, createElement } from "../../../shared/modules/utils.js"; -import state from "../utils/viewState.js"; +import { createZip, getCsv, getTikz } from "../../../api/convertions.api.js"; +import { + createButton, + createElement, + safeFilename, +} from "../../../shared/modules/utils.js"; export default class ExportHandler { constructor(widget) { this.serializer = new XMLSerializer(); this.widget = widget; + this.filename = safeFilename(widget.config.title); + } - const root = widget.root.node ? widget.root.node() : widget.root; + init(bulkExports = false) { + const root = this.widget.root.node + ? this.widget.root.node() + : this.widget.root; const dropdown = root.querySelector(".dv-dropdown-menu"); + const formats = {}; - this.filename = "chart"; - - const formats = { - tex: "bi bi-file-earmark-font", - csv: "bi bi-table", - json: "bi bi-braces", - }; - - if (widget.svg) { + if (this.widget.svg) { formats.svg = "bi bi-file-earmark-code"; formats.png = "bi bi-image"; } - if (dropdown) { + formats.tex = "bi bi-file-earmark-font"; + formats.csv = "bi bi-table"; + formats.json = "bi bi-braces"; + + Object.entries(formats).forEach(([format, icon]) => { + const button = createButton(icon, "Export as " + format, () => { + this.startExport(format, false); + }); + + dropdown.append(button); + }); + + if (bulkExports) { + dropdown.append(createElement("div", { className: "dv-divider" })); + Object.entries(formats).forEach(([format, icon]) => { - const button = createElement( - "button", - { - className: "dv-btn", - type: "button", - onclick: () => this.prepareExport(format), - }, - [ - createElement("i", { className: icon }), - createElement("span", { textContent: "Export as " + format }), - ], - ); + const button = createButton(icon, "Export all as " + format, () => { + this.startExport(format, true); + }); + dropdown.append(button); }); } } - getJSON() { - return this.widget.data || []; - } - - getSVG() { - return this.widget.svg?.node ? this.widget.svg.node() : this.widget.svg; - } - - getMetadata() { - return { ...state.corpusFilter.filter, ...this.widget.filter }; - } + startExport(format, bulk) { + const exporters = { + svg: () => this.widget.export(bulk).then((d) => this.svgExport(d)), + png: () => this.widget.export(bulk).then((d) => this.pngExport(d)), + tex: () => this.widget.export(bulk).then((d) => this.texExport(d)), + csv: () => this.widget.export(bulk).then((d) => this.csvExport(d)), + json: () => this.widget.export(bulk).then((d) => this.jsonExport(d)), + }; - prepareExport(format) { - switch (format) { - case "svg": - this.exportSVG(); - break; - case "png": - this.exportPNG(); - break; - case "tex": - this.exportTEX(); - break; - case "csv": - this.exportCSV(); - break; - case "json": - this.exportJSON(); - break; - } + exporters[format](); } - exportSVG() { + async svgExport({ items, meta }) { const namespace = "http://www.w3.org/2000/svg"; + const header = '\r\n'; const metadata = document.createElementNS(namespace, "metadata"); - const entries = Object.entries(this.getMetadata()); - for (const [key, value] of entries) { + // Prepare metadata node + for (const [key, value] of Object.entries(meta)) { const node = document.createElementNS(namespace, key); node.textContent = value; metadata.appendChild(node); } - const svg = this.getSVG().cloneNode(true); - svg.prepend(metadata); - const header = '\r\n'; - const str = this.serializer.serializeToString(svg); - const url = this.createURL(header + str, "image/svg+xml"); + // Create blobs + const blobs = items.map(({ svg }) => { + const clone = svg.cloneNode(true); + clone.prepend(metadata.cloneNode(true)); - this.downloadURL(url, `${this.filename}.svg`); + const str = this.serializer.serializeToString(clone); + + return new Blob([header + str], { type: "image/svg+xml" }); + }); + + await this.downloadBlobs(blobs, "svg"); } - exportPNG() { - const str = this.serializer.serializeToString(this.getSVG()); - const url = this.createURL(str, "image/svg+xml"); - const img = new Image(); + async pngExport({ items }) { + const blobs = items.map(async ({ svg }) => { + const str = this.serializer.serializeToString(svg); + const url = URL.createObjectURL( + new Blob([str], { type: "image/svg+xml" }), + ); - img.onload = () => { - const bbox = this.getSVG().getBBox(); + const img = new Image(); - const canvas = document.createElement("canvas"); - canvas.width = bbox.width; - canvas.height = bbox.height; + return await new Promise((resolve) => { + img.onload = () => { + URL.revokeObjectURL(url); - const context = canvas.getContext("2d"); - context.drawImage(img, 0, 0, bbox.width, bbox.height); + const width = svg.width.baseVal.value; + const height = svg.height.baseVal.value; - this.downloadURL(canvas.toDataURL(), `${this.filename}.png`); - }; - img.src = url; - } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; - async exportTEX() { - let str = ""; + const context = canvas.getContext("2d"); + context.drawImage(img, 0, 0, width, height); - // Prepare svg if one exists - if (this.widget.svg) { - let svg = this.getSVG().cloneNode(true); + canvas.toBlob((b) => resolve(b), "image/png"); + }; - svg = applyStyles(svg, [ - { selector: '[stroke="currentColor"]', styles: { stroke: "black" } }, - { selector: '[fill="currentColor"]', styles: { fill: "black" } }, - { selector: '[stroke="transparent"]', styles: { stroke: "none" } }, - { selector: '[fill="transparent"]', styles: { fill: "none" } }, - ]); + img.src = url; + }); + }); - str = this.serializer.serializeToString(svg); - } + await this.downloadBlobs(await Promise.all(blobs), "png"); + } - const type = this.widget.constructor.defaultConfig.type; - const json = this.getJSON(); - const meta = { - metadata: this.getMetadata(), - options: this.widget.config.options, - }; + async texExport({ items, meta }) { + const type = this.widget.config.type; - const data = await getTikz(type, str, json, meta); - const url = this.createURL(data.content, "application/x-tex"); + const blobs = items.map(async ({ svg, json }) => { + const str = svg ? this.serializer.serializeToString(svg) : ""; - this.downloadURL(url, `${this.filename}.tex`); - } + const data = await getTikz(type, str, json, { + metadata: meta, + options: this.widget.config.options, + }); - exportCSV() { - const json = this.getJSON(); - const keys = Object.keys(json[0]); - const entries = Object.entries(this.getMetadata()); + return new Blob([data.content], { type: "application/x-tex" }); + }); - const escape = (value) => { - const str = String(value); - return /[",\n]/.test(str) ? '"' + str.replace(/"/g, '""') + '"' : str; - }; + await this.downloadBlobs(await Promise.all(blobs), "tex"); + } - const metadata = entries.map(([k, v]) => `# ${k}: ${v}`); - const header = keys.join(","); - const rows = json.map((o) => keys.map((k) => escape(o[k])).join(",")); + async csvExport({ items, meta }) { + const type = this.widget.config.type; - const str = [...metadata, header, ...rows].join("\r\n"); - const url = this.createURL(str, "text/csv"); + const blobs = items.map(async ({ json }) => { + const data = await getCsv(type, json, meta); - this.downloadURL(url, `${this.filename}.csv`); + return new Blob([data.content], { type: "text/csv" }); + }); + + await this.downloadBlobs(await Promise.all(blobs), "csv"); } - exportJSON() { - const json = { - metadata: this.getMetadata(), - data: this.getJSON(), - }; - const str = JSON.stringify(json, null, 2); - const url = this.createURL(str, "application/json"); + async jsonExport({ items, meta }) { + const blobs = items.map(({ json }) => { + const str = JSON.stringify({ metadata: meta, data: json }, null, 2); - this.downloadURL(url, `${this.filename}.json`); + return new Blob([str], { type: "application/json" }); + }); + + await this.downloadBlobs(blobs, "json"); } - createURL(str, type) { - return URL.createObjectURL(new Blob([str], { type })); + async downloadBlobs(blobs, type) { + if (blobs.length > 1) { + const zip = await createZip(blobs, type); + this.downloadSingleBlob(zip, `${this.filename}.zip`); + } else { + this.downloadSingleBlob(blobs[0], `${this.filename}.${type}`); + } } - downloadURL(url, name) { + downloadSingleBlob(blob, name) { + const url = URL.createObjectURL(blob); const a = document.createElement("a"); + a.href = url; a.download = name; - a.click(); a.remove(); diff --git a/src/main/resources/static/js/shared/classes/ChartGPT.js b/src/main/resources/static/js/shared/classes/ChatBot.js similarity index 93% rename from src/main/resources/static/js/shared/classes/ChartGPT.js rename to src/main/resources/static/js/shared/classes/ChatBot.js index b81b26e6..fa0e4159 100644 --- a/src/main/resources/static/js/shared/classes/ChartGPT.js +++ b/src/main/resources/static/js/shared/classes/ChatBot.js @@ -3,7 +3,7 @@ import state from "../../pages/view/utils/viewState.js"; import { svgToBase64 } from "../modules/convert.js"; import { createElement } from "../modules/utils.js"; -export default class ChartGPT { +export default class ChatBot { constructor(instruction) { const root = document.querySelector(".dv-chat-bot"); this.chat = root.querySelector(".dv-chat-messages"); @@ -86,8 +86,7 @@ export default class ChartGPT { if (chart) { content.push({ type: "image_url", - image_url: { url: svgToBase64(chart.svg.node()) }, - widget: chart.config.title, + image_url: { url: await svgToBase64(chart.svg.node()) }, }); } @@ -150,16 +149,13 @@ export default class ChartGPT { if (item.type === "text") { string += item.text; } else if (item.type === "image_url") { - string += `
[Context: ${item.widget}]`; + string += ``; } }); } else { string = content; } - string.replace("", ""); - string.replace("", ""); - return DOMPurify.sanitize(marked.parse(string)); } } diff --git a/src/main/resources/static/js/shared/classes/FormBuilder.js b/src/main/resources/static/js/shared/classes/FormBuilder.js index da6990ac..88bd700d 100644 --- a/src/main/resources/static/js/shared/classes/FormBuilder.js +++ b/src/main/resources/static/js/shared/classes/FormBuilder.js @@ -21,7 +21,7 @@ export default class FormBuilder { const fields = []; for (const [key, value] of Object.entries(config)) { - if (isObject(value)) { + if (isObject(value) && !this.formConfig[[...path, key].join(".")]) { fields.push(...this.parseConfig(value, [...path, key])); } else { fields.push(this.createInputField([...path, key].join("."), value)); @@ -33,7 +33,7 @@ export default class FormBuilder { writeToConfig(config, formData, path = []) { for (const [key, value] of Object.entries(config)) { - if (isObject(value)) { + if (isObject(value) && !this.formConfig[[...path, key].join(".")]) { config[key] = this.writeToConfig(value, formData, [...path, key]); } else { config[key] = this.parseValue([...path, key].join("."), formData); @@ -70,12 +70,13 @@ export default class FormBuilder { parseValue(key, formData) { const config = this.formConfig[key]; - switch (config.type) { + switch (config?.type) { case "number": case "range": return Number(formData.get(key)); case "multiselect": + case "json": return JSON.parse(formData.get(key)); case "switch": diff --git a/src/main/resources/static/js/shared/classes/Multiselect.js b/src/main/resources/static/js/shared/classes/Multiselect.js index 1c89642d..f1fef269 100644 --- a/src/main/resources/static/js/shared/classes/Multiselect.js +++ b/src/main/resources/static/js/shared/classes/Multiselect.js @@ -4,40 +4,39 @@ export default class Multiselect { constructor(options) { this.options = options; this.selection = []; - this.dom = {}; } commitSelection() { - this.dom.input.value = JSON.stringify(this.selection); + this.input.value = JSON.stringify(this.selection); } create(key, selected) { const template = document.querySelector("#multiselect-template"); const root = template.content.cloneNode(true); - this.dom.input = root.querySelector("input"); - this.dom.trigger = root.querySelector(".dv-multiselect"); - this.dom.pills = root.querySelector(".dv-multiselect-pills"); - this.dom.dropdown = root.querySelector(".dv-dropdown"); + this.input = root.querySelector("input"); + this.trigger = root.querySelector(".dv-multiselect"); + this.pills = root.querySelector(".dv-multiselect-pills"); + this.dropdown = root.querySelector(".dv-dropdown"); - this.dom.input.name = key; + this.input.name = key; for (const option of this.options) { if (selected.includes(option.value)) { const node = this.createPill(option.label, option.value); - this.dom.pills.append(node); + this.pills.append(node); this.selection.push(option.value); } else { const node = this.createOption(option.label, option.value); - this.dom.dropdown.append(node); + this.dropdown.append(node); } } - this.dom.trigger.addEventListener("focus", () => this.show()); - this.dom.trigger.addEventListener("blur", () => this.hide()); - this.dom.dropdown.addEventListener("mousedown", (event) => - event.preventDefault() + this.trigger.addEventListener("focus", () => this.show()); + this.trigger.addEventListener("blur", () => this.hide()); + this.dropdown.addEventListener("mousedown", (event) => + event.preventDefault(), ); this.commitSelection(); @@ -53,7 +52,7 @@ export default class Multiselect { option.addEventListener("click", () => { const node = this.createPill(label, value); - this.dom.pills.append(node); + this.pills.append(node); option.remove(); @@ -76,7 +75,7 @@ export default class Multiselect { icon.addEventListener("click", () => { const node = this.createOption(label, value); - this.dom.dropdown.append(node); + this.dropdown.append(node); pill.remove(); @@ -89,10 +88,36 @@ export default class Multiselect { } show() { - this.dom.dropdown.classList.add("show"); + this.dropdown.classList.add("show"); + this.updatePosition(); } hide() { - this.dom.dropdown.classList.remove("show"); + this.dropdown.classList.remove("show"); + this.cleanup(); + } + + updatePosition() { + const { autoUpdate, computePosition, size, hide } = FloatingUIDOM; + + // Position dropdown and update on reference move + this.cleanup = autoUpdate(this.trigger, this.dropdown, () => { + computePosition(this.trigger, this.dropdown, { + middleware: [ + size({ + apply({ rects, elements }) { + elements.floating.style.width = rects.reference.width + "px"; + }, + }), + hide(), + ], + }).then(({ x, y, middlewareData }) => { + this.dropdown.style.visibility = middlewareData.hide.referenceHidden + ? "hidden" + : "visible"; + this.dropdown.style.left = x + "px"; + this.dropdown.style.top = y + "px"; + }); + }); } } diff --git a/src/main/resources/static/js/shared/classes/Searchselect.js b/src/main/resources/static/js/shared/classes/Searchselect.js index 7b94e06d..c1467ae1 100644 --- a/src/main/resources/static/js/shared/classes/Searchselect.js +++ b/src/main/resources/static/js/shared/classes/Searchselect.js @@ -1,50 +1,47 @@ import { createElement } from "../modules/utils.js"; export default class Searchselect { - constructor({ getData, keys = ["", ""], header = ["Results", ""] }) { + constructor({ getData, keys = ["", ""], headers = ["Results", ""] }) { this.getData = getData; this.keys = keys; - this.header = header; + this.headers = headers; this.value = ""; - this.dom = {}; } create(key, selected) { const template = document.querySelector("#searchselect-template"); const root = template.content.cloneNode(true); - this.dom.input = root.querySelector("input"); - this.dom.dropdown = root.querySelector(".dv-dropdown"); - this.dom.header = root.querySelectorAll(".dv-searchselect-header>span"); - this.dom.container = root.querySelector(".dv-searchselect-container"); + this.reference = root.querySelector(".dv-searchselect-input"); + this.input = root.querySelector("input"); + this.dropdown = root.querySelector(".dv-dropdown"); + this.header = root.querySelectorAll(".dv-searchselect-header>span"); + this.container = root.querySelector(".dv-searchselect-container"); - this.dom.header[0].textContent = this.header[0]; - this.dom.header[1].textContent = this.header[1]; - this.dom.input.name = key; - this.dom.input.value = selected; + this.header[0].textContent = this.headers[0]; + this.header[1].textContent = this.headers[1]; + this.input.name = key; + this.input.value = selected; this.value = selected; - this.dom.input.addEventListener("focus", () => { - this.dom.input.select(); - this.autocomplete(this.dom.input.value, 20); + this.input.addEventListener("focus", () => { + this.input.select(); + this.autocomplete(this.input.value, 20); }); - this.dom.input.addEventListener("blur", () => { - this.dom.input.value = this.value; + this.input.addEventListener("blur", () => { + this.input.value = this.value; this.hide(); this.clear(); }); let timeout = null; - this.dom.input.addEventListener("input", () => { + this.input.addEventListener("input", () => { clearTimeout(timeout); - timeout = setTimeout( - () => this.autocomplete(this.dom.input.value, 20), - 300, - ); + timeout = setTimeout(() => this.autocomplete(this.input.value, 20), 300); }); - this.dom.dropdown.addEventListener("mousedown", (event) => { + this.dropdown.addEventListener("mousedown", (event) => { event.preventDefault(); }); @@ -58,9 +55,18 @@ export default class Searchselect { items.forEach((item) => { const result = this.createResult(item); - this.dom.container.append(result); + this.container.append(result); }); + if (items.length === 0) { + this.container.append( + createElement("span", { + className: "m-1", + textContent: "No results found", + }), + ); + } + this.show(); }) .catch((error) => { @@ -70,14 +76,9 @@ export default class Searchselect { createResult(item) { const label = createElement("span", { - className: "dv-text-truncate", + className: "dv-text-truncate-left", textContent: item[this.keys[0]] || item, }); - label.addEventListener("click", (event) => { - event.preventDefault(); - this.value = item[this.keys[0]] || item; - this.dom.input.blur(); - }); const info = createElement("span", { textContent: item[this.keys[1]] || "", @@ -92,18 +93,50 @@ export default class Searchselect { [label, info], ); + result.addEventListener("click", (event) => { + event.preventDefault(); + this.value = item[this.keys[0]] || item; + this.input.blur(); + }); + return result; } show() { - this.dom.dropdown.classList.add("show"); + this.dropdown.classList.add("show"); + this.updatePosition(); } hide() { - this.dom.dropdown.classList.remove("show"); + this.dropdown.classList.remove("show"); + this.cleanup(); } clear() { - this.dom.container.innerHTML = ""; + this.container.innerHTML = ""; + } + + updatePosition() { + const { autoUpdate, computePosition, size, hide } = FloatingUIDOM; + + // Position dropdown and update on reference move + this.cleanup = autoUpdate(this.reference, this.dropdown, () => { + computePosition(this.reference, this.dropdown, { + middleware: [ + size({ + apply({ rects, elements }) { + elements.floating.style.width = rects.reference.width + "px"; + }, + }), + hide(), + ], + }).then(({ x, y, middlewareData }) => { + this.dropdown.style.visibility = middlewareData.hide.referenceHidden + ? "hidden" + : "visible"; + this.dropdown.style.left = x + "px"; + this.dropdown.style.top = y + "px"; + }); + }); } } diff --git a/src/main/resources/static/js/shared/classes/WidgetPagination.js b/src/main/resources/static/js/shared/classes/WidgetPagination.js new file mode 100644 index 00000000..a9c17f75 --- /dev/null +++ b/src/main/resources/static/js/shared/classes/WidgetPagination.js @@ -0,0 +1,92 @@ +import { createElement } from "../modules/utils.js"; + +export default class WidgetPagination { + constructor({ pageSize = 1, container, onPageChange }) { + this.pageSize = pageSize; + this.container = container; + this.onPageChange = onPageChange; + this.currentPage = 0; + this.elements = []; + } + + get totalPages() { + return Math.ceil(this.elements.length / this.pageSize); + } + + init(elements) { + this.elements = elements; + + if (elements.length > 1) { + this.createControls(); + this.updateIndicator(); + } + } + + createControls() { + this.btnPrevious = this.createButton("left"); + this.btnNext = this.createButton("right"); + this.indicator = this.createIndicator(); + this.dropdown = this.createDropdown(); + + this.btnPrevious.addEventListener("click", () => + this.setPage(this.currentPage - 1), + ); + this.btnNext.addEventListener("click", () => + this.setPage(this.currentPage + 1), + ); + this.dropdown.addEventListener("change", (e) => + this.setPage(parseInt(e.target.value)), + ); + + const controls = createElement( + "div", + { className: "dv-pagination-controls" }, + [this.dropdown, this.btnPrevious, this.btnNext, this.indicator], + ); + + this.container.append(controls); + } + + setPage(page) { + // handle both edge cases: going forward from the last page wraps to 0, + // and going back from page 0 wraps to the last page. + this.currentPage = (page + this.totalPages) % this.totalPages; + + this.onPageChange(); + this.updateIndicator(); + this.dropdown.value = this.currentPage; + } + + updateIndicator() { + this.indicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`; + } + + createButton(side = "left") { + const btnClass = side === "right" ? "next" : "prev"; + const title = side === "right" ? "Next" : "Previous"; + const icon = "bi bi-chevron-" + side; + + return createElement( + "button", + { className: "dv-btn-pagination " + btnClass, type: "button", title }, + [createElement("i", { className: icon })], + ); + } + + createIndicator() { + return createElement("span", { + className: "dv-pagination-indicator", + textContent: "0 / 0", + }); + } + + createDropdown() { + return createElement( + "select", + { className: "dv-pagination-dropdown" }, + this.elements.map((opt, index) => + createElement("option", { textContent: opt, value: index }), + ), + ); + } +} diff --git a/src/main/resources/static/js/shared/modules/clustering.js b/src/main/resources/static/js/shared/modules/clustering.js new file mode 100644 index 00000000..63ba4d6c --- /dev/null +++ b/src/main/resources/static/js/shared/modules/clustering.js @@ -0,0 +1,237 @@ +// https://d3js.org/d3-quadtree +export function quadtreeClustering(points, rows, cols, cellSize) { + const tree = d3.quadtree(points); + const clusterPoints = []; + + function searchInTree(xmin, ymin, xmax, ymax) { + const results = []; + + tree.visit((node, x1, y1, x2, y2) => { + if (!node.length) { + do { + let d = node.data; + if (d[0] >= xmin && d[0] < xmax && d[1] >= ymin && d[1] < ymax) { + results.push(d); + } + } while ((node = node.next)); + } + return x1 >= xmax || y1 >= ymax || x2 < xmin || y2 < ymin; + }); + + return results; + } + + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const found = searchInTree( + x * cellSize, + y * cellSize, + x * cellSize + cellSize, + y * cellSize + cellSize, + ); + + for (const f of found) { + f.push(found.length); + clusterPoints.push(f); + } + } + } + + return clusterPoints; +} + +// https://dl.acm.org/doi/10.5555/3001460.3001507 +export function dbscanClustering(data, points, epsilon, minPts) { + function euclideanDist(a, b) { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + return Math.sqrt(dx * dx + dy * dy); + } + + function rangeQuery(idx, epsilon) { + return points.reduce((neighbors, point, i) => { + if (euclideanDist(points[idx], point) <= epsilon) neighbors.push(i); + return neighbors; + }, []); + } + + function dbscan(points, epsilon, minPts) { + const labels = new Array(points.length).fill(undefined); + let clusterId = 0; + + for (let i = 0; i < points.length; i++) { + if (labels[i] !== undefined) continue; + + const neighbors = rangeQuery(i, epsilon); + + if (neighbors.length < minPts) { + labels[i] = -1; // noise + continue; + } + + labels[i] = clusterId; + + const seeds = neighbors.filter((n) => n !== i); + + for (let j = 0; j < seeds.length; j++) { + const s = seeds[j]; + + if (labels[s] === -1) labels[s] = clusterId; + if (labels[s] !== undefined) continue; + + labels[s] = clusterId; + + const newNeighbors = rangeQuery(s, epsilon); + if (newNeighbors.length >= minPts) { + seeds.push( + ...newNeighbors.filter((n) => !seeds.includes(n) && n !== s), + ); + } + } + + clusterId++; + } + + return labels; + } + + const labels = dbscan(points, epsilon, minPts); + const groups = d3.group(data, (_, i) => labels[i]); + groups.delete(-1); + + const clusterPoints = []; + + points.forEach((p, i) => { + const found = groups.get(labels[i]); + p.push(found ? found.length : 1); + clusterPoints.push(p); + }); + + return clusterPoints; +} + +// https://github.com/d3/d3-delaunay +export function voronoiClustering(points, width, height) { + if (points.length < 2) { + return points.map((p) => [p[0], p[1], 1]); + } + + const delaunay = d3.Delaunay.from(points); + const voronoi = delaunay.voronoi([0, 0, width, height]); + + // Collect all cell areas first so we can normalise + const areas = points.map((_, i) => { + const poly = voronoi.cellPolygon(i); + return poly ? Math.abs(d3.polygonArea(poly)) : Infinity; + }); + + const maxArea = d3.max(areas.filter(isFinite)); + + return points.map((p, i) => { + const area = areas[i]; + // Small area = dense neighbourhood → large radius + // Clamp edge cells (Infinity) to a radius of 1 + const radius = isFinite(area) ? (1 - area / maxArea) * 10 + 1 : 1; + return [p[0], p[1], radius]; + }); +} + +// k-th nearest neighbour distance as density proxy +export function knnClustering(points, k = 5) { + if (points.length <= k) { + return points.map((p) => [p[0], p[1], 1]); + } + + // For each point, find the distance to its k-th nearest neighbour + const kDistances = points.map((p, i) => { + const sorted = points + .map((q, j) => ({ j, dist: Math.hypot(p[0] - q[0], p[1] - q[1]) })) + .filter(({ j }) => j !== i) // exclude self + .sort((a, b) => a.dist - b.dist); + + return sorted[k - 1]?.dist ?? Infinity; + }); + + const maxDist = d3.max(kDistances.filter(isFinite)); + + return points.map((p, i) => { + const d = kDistances[i]; + // Short distance = dense area → large radius + const radius = isFinite(d) ? (1 - d / maxDist) * 10 + 1 : 1; + return [p[0], p[1], radius]; + }); +} + +// https://github.com/d3/d3-delaunay +export function delaunayClustering(points) { + if (points.length < 2) { + return points.map((p) => [p[0], p[1], 1]); + } + + const delaunay = d3.Delaunay.from(points); + + // Build adjacency: for each point collect the lengths of its Delaunay edges + const edgeLengths = points.map(() => []); + + // delaunay.triangles is a flat array [i0, i1, i2, i3, i4, i5, ...] + for (let t = 0; t < delaunay.triangles.length; t += 3) { + const a = delaunay.triangles[t]; + const b = delaunay.triangles[t + 1]; + const c = delaunay.triangles[t + 2]; + + const pairs = [ + [a, b], + [b, c], + [a, c], + ]; + for (const [i, j] of pairs) { + const len = Math.hypot( + points[i][0] - points[j][0], + points[i][1] - points[j][1], + ); + edgeLengths[i].push(len); + edgeLengths[j].push(len); + } + } + + // Average edge length per point; isolated points fall back to Infinity + const avgLengths = edgeLengths.map((lens) => + lens.length ? lens.reduce((s, l) => s + l, 0) / lens.length : Infinity, + ); + + const maxLen = d3.max(avgLengths.filter(isFinite)); + + return points.map((p, i) => { + const avg = avgLengths[i]; + // Short edge = dense → large radius + const radius = isFinite(avg) ? (1 - avg / maxLen) * 10 + 1 : 1; + return [p[0], p[1], radius]; + }); +} + +// Gaussian kernel density estimation evaluated at each point +export function gaussianKdeClustering(points, bandwidth = 30) { + if (points.length < 2) { + return points.map((p) => [p[0], p[1], 1]); + } + + const h2 = bandwidth * bandwidth; + + // KDE value at point i = sum of Gaussian contributions from all other points + const densities = points.map((p, i) => { + let sum = 0; + for (let j = 0; j < points.length; j++) { + if (i === j) continue; + const distSq = (p[0] - points[j][0]) ** 2 + (p[1] - points[j][1]) ** 2; + sum += Math.exp(-distSq / (2 * h2)); + } + return sum; // proportional to density, normalisation not needed here + }); + + const maxDensity = d3.max(densities); + + return points.map((p, i) => { + const radius = (densities[i] / maxDensity) * 10 + 1; + return [p[0], p[1], radius]; + }); +} diff --git a/src/main/resources/static/js/shared/modules/clustering.md b/src/main/resources/static/js/shared/modules/clustering.md new file mode 100644 index 00000000..630e4176 --- /dev/null +++ b/src/main/resources/static/js/shared/modules/clustering.md @@ -0,0 +1,9 @@ +| Method | Signal | Hyperparameters | Smoothness | Edge sensitivity | O complexity | +| ------------------------- | --------------------------------- | --------------------- | ----------- | -------------------- | ------------ | +| Quadtree | Count of points in grid cell | Cell size | Blocky | Low | O(n log n) | +| DBSCAN | Cluster membership size | ε, minPts | Binary | Low | O(n²) | +| Voronoi area | Inverse Voronoi cell area | None | Moderate | High (clipped cells) | O(n log n) | +| kNN distance | Inverse k-th neighbour distance | k | Moderate | Low | O(n²) | +| Delaunay edge length | Average Delaunay edge length | None | Smooth | Low | O(n log n) | +| Gaussian KDE | Gaussian kernel sum at each point | Bandwidth | Very smooth | Low | O(n²) | +| Kernel Density Estimation | KDE over full canvas grid | Bandwidth, thresholds | Very smooth | None | O(n·w·h) | diff --git a/src/main/resources/static/js/shared/modules/convert.js b/src/main/resources/static/js/shared/modules/convert.js index 9c9dc975..1d9583ec 100644 --- a/src/main/resources/static/js/shared/modules/convert.js +++ b/src/main/resources/static/js/shared/modules/convert.js @@ -1,13 +1,31 @@ const serializer = new XMLSerializer(); -export function svgToUrl(svg) { - const string = serializer.serializeToString(svg); - const encoded = encodeURIComponent(string); - return `data:image/svg+xml,${encoded}`; -} +export async function svgToBase64(svg) { + const str = serializer.serializeToString(svg); + + const blob = new Blob([str], { + type: "image/svg+xml", + }); + const url = URL.createObjectURL(blob); + + const img = new Image(); + + return new Promise((resolve) => { + img.onload = () => { + URL.revokeObjectURL(url); + + const bbox = svg.getBBox(); + + const canvas = document.createElement("canvas"); + canvas.width = bbox.width; + canvas.height = bbox.height; + + const context = canvas.getContext("2d"); + context.drawImage(img, 0, 0, bbox.width, bbox.height); + + resolve(canvas.toDataURL("image/png")); + }; -export function svgToBase64(svg) { - const string = serializer.serializeToString(svg); - const encoded = btoa(encodeURIComponent(string)); - return `data:image/svg+xml;base64,${encoded}`; + img.src = url; + }); } diff --git a/src/main/resources/static/js/shared/modules/dropdowns.js b/src/main/resources/static/js/shared/modules/dropdowns.js deleted file mode 100644 index 1e7ac2cf..00000000 --- a/src/main/resources/static/js/shared/modules/dropdowns.js +++ /dev/null @@ -1,7 +0,0 @@ -function init() { - document.querySelectorAll("[data-bs-toggle='dropdown']").forEach((node) => { - new bootstrap.Dropdown(node); - }); -} - -export default { init }; diff --git a/src/main/resources/static/js/shared/modules/inputFactories.js b/src/main/resources/static/js/shared/modules/inputFactories.js index 5a775e84..c49a6d02 100644 --- a/src/main/resources/static/js/shared/modules/inputFactories.js +++ b/src/main/resources/static/js/shared/modules/inputFactories.js @@ -1,6 +1,6 @@ import Multiselect from "../classes/Multiselect.js"; import Searchselect from "../classes/Searchselect.js"; -import { createElement } from "./utils.js"; +import { createElement, isJson } from "./utils.js"; export default { text(key, value, _, onchange) { @@ -157,4 +157,42 @@ export default { onchange, }); }, + + json( + key, + value, + { rows = 1, validator = () => true, message = "Invalid json" }, + onchange, + ) { + const json = JSON.stringify(value, null, 2); + + const input = createElement("textarea", { + name: key, + value: json, + className: "dv-text-input dv-resize-none font-monospace", + rows, + onchange, + }); + const feedback = createElement("div", { + className: "dv-invalid-feedback", + textContent: message, + }); + + input.addEventListener("input", (e) => { + if (isJson(e.target.value) && validator(JSON.parse(e.target.value))) { + feedback.style.removeProperty("display", "block"); + } else { + feedback.style.setProperty("display", "block"); + } + }); + + input.addEventListener("change", (e) => { + if (!isJson(e.target.value) || !validator(JSON.parse(e.target.value))) { + input.value = json; + feedback.style.removeProperty("display", "block"); + } + }); + + return createElement("div", {}, [input, feedback]); + }, }; diff --git a/src/main/resources/static/js/shared/modules/instruction.js b/src/main/resources/static/js/shared/modules/instruction.js new file mode 100644 index 00000000..453bd316 --- /dev/null +++ b/src/main/resources/static/js/shared/modules/instruction.js @@ -0,0 +1,52 @@ +export const instruction = ` +You are **ChartBot**, a helpful and precise assistant designed to answer questions about charts, graphs, and data visualizations displayed on this dashboard. + +Your primary goal is to help users understand, interpret, and extract insights from the data presented. + +### Core Responsibilities + +* Explain what the chart shows in clear, simple terms. +* Interpret trends, patterns, and anomalies in the data. +* Answer specific questions about values, comparisons, and relationships. +* Provide context for what the data might mean when possible. + +### Behavior Guidelines + +* Be concise, but include enough detail to fully answer the question. +* Use plain language; avoid unnecessary jargon unless the user asks for it. +* When referencing the chart, describe elements explicitly (e.g., axes, labels, colors, legends). +* If exact values are visible, use them. If not, provide reasonable approximations and clearly state that they are estimates. +* Do not invent data that is not visible or implied by the chart. + +### Reasoning Rules + +* Base your answers strictly on the data shown in the chart and any accompanying labels or descriptions. +* If the question cannot be answered from the chart, say so clearly. +* If the chart is ambiguous or unclear, explain the uncertainty rather than guessing. + +### Interaction Style + +* Be neutral and factual, not opinionated. +* Do not speculate beyond the data. +* If helpful, suggest what additional data or chart type could clarify the user's question. + +### Handling Ambiguity + +* If the user's question is unclear, ask a brief follow-up question before answering. +* If multiple interpretations are possible, present them clearly. + +### Example Capabilities + +* “What is the highest value shown?” +* “How did sales change from January to June?” +* “Which category grew the fastest?” +* “Is there a trend over time?” + +### Limitations + +* You do not have access to external data unless explicitly provided. +* You cannot see anything outside the chart or its description. +* You cannot modify the chart, only interpret it. + +Stay focused on helping the user understand the chart as accurately and clearly as possible. +`; diff --git a/src/main/resources/static/js/shared/modules/utils.js b/src/main/resources/static/js/shared/modules/utils.js index a512a824..a5a87406 100644 --- a/src/main/resources/static/js/shared/modules/utils.js +++ b/src/main/resources/static/js/shared/modules/utils.js @@ -12,6 +12,15 @@ export function isObject(item) { return typeof item === "object" && !Array.isArray(item) && item !== null; } +export function isJson(str) { + try { + JSON.parse(str); + } catch (e) { + return false; + } + return true; +} + export function debounce(fn, timeout = 300) { let timer; return (...args) => { @@ -43,6 +52,21 @@ export function createTemplateElement( return element; } +export function createButton(icon, textContent, onclick) { + return createElement( + "button", + { + className: "dv-btn", + type: "button", + onclick, + }, + [ + createElement("i", { className: icon }), + createElement("span", { textContent }), + ], + ); +} + export function deepClone(object, skip = []) { // Make a copy excluding skipped keys const copy = {}; @@ -74,3 +98,7 @@ export function applyStyles(node, changes) { return node.node(); } + +export function safeFilename(str) { + return str.replace(/[<>:"/\\|?*\x00-\x1F]/g, "").replace(/\s+/g, "-"); +} diff --git a/src/main/resources/static/js/widgets/D3Visualization.js b/src/main/resources/static/js/widgets/D3Visualization.js index 45db9bc9..499bffa2 100644 --- a/src/main/resources/static/js/widgets/D3Visualization.js +++ b/src/main/resources/static/js/widgets/D3Visualization.js @@ -1,15 +1,10 @@ -import { getData } from "../api/data.api.js"; -import ControlsHandler from "../pages/view/toolbar/ControlsHandler.js"; -import ExportHandler from "../pages/view/toolbar/ExportHandler.js"; -import state from "../pages/view/utils/viewState.js"; import { debounce, randomId } from "../shared/modules/utils.js"; +import WidgetInterface from "./WidgetInterface.js"; +import state from "../pages/view/utils/viewState.js"; -export default class D3Visualization { +export default class D3Visualization extends WidgetInterface { constructor(root, config, margin) { - this.root = d3.select(root); - this.config = config; - - this.setTitle(this.config.title); + super(root, config); const { width, height } = this.getDimensions(); this.width = width - margin.left - margin.right; @@ -18,11 +13,6 @@ export default class D3Visualization { this.tooltip = d3.select(".dv-chart-tooltip"); this.svg = this.root.select(".dv-chart-area").append("svg"); - this.data = null; - - this.filter = {}; - this.controls = new ControlsHandler(this); - this.exports = new ExportHandler(this); // Re-render chart on resize of container const observer = new ResizeObserver( @@ -36,10 +26,6 @@ export default class D3Visualization { observer.observe(root); } - setTitle(title) { - this.root.select(".dv-toolbar-title").attr("title", title).text(title); - } - getDimensions() { const area = this.root.select(".dv-chart-area").node(); const rect = area.getBoundingClientRect(); @@ -51,16 +37,7 @@ export default class D3Visualization { this.width = width - this.margin.left - this.margin.right; this.height = height - this.margin.top - this.margin.bottom; - this.render(this.data); - } - - async fetch() { - const { pipeline, generator, type } = this.config; - - return await getData(pipeline, generator.id, type, { - corpus: state.corpusFilter.filter, - chart: this.filter, - }); + this.rerender(); } clear() { @@ -73,12 +50,34 @@ export default class D3Visualization { .attr("transform", `translate(${this.margin.left}, ${this.margin.top})`); } - init() { - throw new Error("Method init() not implemented."); - } + async export(all = false) { + let items = [{ json: this.data, svg: this.svg.node() }]; + + if (all) { + // Save current state + const snapshot = { svg: this.svg, data: this.data }; + const { data } = await this.fetch(true); + + // Render new svgs in background + items = data.map((dataset) => { + this.svg = d3.create("svg"); + this.render(dataset.data[0]); - render() { - throw new Error("Method render() not implemented."); + return { json: this.data, svg: this.svg.node() }; + }); + + // Restore current state + this.svg = snapshot.svg; + this.data = snapshot.data; + } + + return { + items, + meta: { + corpus: state.corpusFilter.filter, + chart: this.filter, + }, + }; } mouseover(event) { diff --git a/src/main/resources/static/js/widgets/WidgetInterface.js b/src/main/resources/static/js/widgets/WidgetInterface.js new file mode 100644 index 00000000..7196a705 --- /dev/null +++ b/src/main/resources/static/js/widgets/WidgetInterface.js @@ -0,0 +1,80 @@ +import { getData } from "../api/data.api.js"; +import ControlsHandler from "../pages/view/toolbar/ControlsHandler.js"; +import ExportHandler from "../pages/view/toolbar/ExportHandler.js"; +import state from "../pages/view/utils/viewState.js"; +import WidgetPagination from "../shared/classes/WidgetPagination.js"; + +export default class WidgetInterface { + static defaultConfig; + static formConfig; + + constructor(root, config) { + this.root = d3.select(root); + this.config = config; + this.data = null; + + this.setTitle(config.title); + + this.filter = {}; + this.controls = new ControlsHandler(this); + this.exports = new ExportHandler(this); + + this.pagination = new WidgetPagination({ + container: this.root.select(".dv-chart-area").node(), + onPageChange: () => this.rerender(true), + }); + } + + setTitle(title) { + this.root.select(".dv-toolbar-title").attr("title", title).text(title); + } + + async fetch(all = false) { + const { pipeline, generator, type } = this.config; + const page = all ? 1 : this.pagination.currentPage; + const size = all ? this.pagination.elements.length : 1; + + return await getData(pipeline, generator.id, type, page, size, { + corpus: state.corpusFilter.filter, + chart: this.filter, + }); + } + + clear() { + throw new Error("Method clear() not implemented."); + } + + init() { + throw new Error("Method init() not implemented."); + } + + render() { + throw new Error("Method render() not implemented."); + } + + rerender(fetch = false) { + if (fetch) { + this.fetch().then(({ data }) => this.render(data[0])); + } else { + this.render(this.data); + } + } + + async export(all = false) { + let items = [{ json: this.data }]; + + if (all) { + const { data } = await this.fetch(true); + + items = data.map((dataset) => ({ json: dataset.data[0] })); + } + + return { + items, + meta: { + corpus: state.corpusFilter.filter, + chart: this.filter, + }, + }; + } +} diff --git a/src/main/resources/static/js/widgets/charts/BarChart.js b/src/main/resources/static/js/widgets/charts/BarChart.js index 8bea8a45..e60c26a6 100644 --- a/src/main/resources/static/js/widgets/charts/BarChart.js +++ b/src/main/resources/static/js/widgets/charts/BarChart.js @@ -21,30 +21,13 @@ export default class BarChart extends D3Visualization { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("CategoryNumber"), + options: () => getGeneratorOptions(["CategoryNumber"]), }, "options.horizontal": { type: "switch", label: "Horizontal", }, }; - static previewData = [ - { - label: "Label 1", - value: 140, - color: "#00618f", - }, - { - label: "Label 2", - value: 73, - color: "#3a4856", - }, - { - label: "Label 3", - value: 56, - color: "#9eadbd", - }, - ]; constructor(root, config) { super(root, config, { top: 30, right: 30, bottom: 70, left: 60 }); @@ -53,17 +36,20 @@ export default class BarChart extends D3Visualization { } async init() { - const data = await this.fetch(); - this.render(data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); - const min = d3.min(data.map((d) => d.value)); - const max = d3.max(data.map((d) => d.value)); + const max = d3.max(data[0].map((d) => d.value)); this.filter = { sort: "value", desc: true, - min, - max, + min: 0, + max: max, + limit: 100 }; this.controls.append([ { @@ -73,7 +59,7 @@ export default class BarChart extends D3Visualization { options: ["value", "label"], onchange: (event) => { this.filter.sort = event.target.value; - this.fetch().then((data) => this.render(data)); + this.rerender(true); }, }, { @@ -82,18 +68,28 @@ export default class BarChart extends D3Visualization { value: this.filter.desc, onchange: (event) => { this.filter.desc = event.target.checked; - this.fetch().then((data) => this.render(data)); + this.rerender(true); }, }, { type: "rangedouble", label: "Range", value: [this.filter.min, this.filter.max], - options: { min, max }, + options: { min: 0, max: max }, onchange: (min, max) => { this.filter.min = min; this.filter.max = max; - this.fetch().then((data) => this.render(data)); + this.rerender(true); + }, + }, + { + type: "number", + label: "Limit", + value: this.filter.limit, + options: { min: 0, max: 10000 }, + onchange: (event) => { + this.filter.limit = event.target.value; + this.rerender(true); }, }, ]); diff --git a/src/main/resources/static/js/widgets/charts/BoundaryApproximation.js b/src/main/resources/static/js/widgets/charts/BoundaryApproximation.js index f521782a..9aa18964 100644 --- a/src/main/resources/static/js/widgets/charts/BoundaryApproximation.js +++ b/src/main/resources/static/js/widgets/charts/BoundaryApproximation.js @@ -1,5 +1,13 @@ import D3Visualization from "../D3Visualization.js"; import { getGeneratorOptions } from "../../pages/editor/utils/editorActions.js"; +import { + dbscanClustering, + quadtreeClustering, + voronoiClustering, + knnClustering, + delaunayClustering, + gaussianKdeClustering, +} from "../../shared/modules/clustering.js"; export default class BoundaryApproximation extends D3Visualization { static defaultConfig = { @@ -8,15 +16,7 @@ export default class BoundaryApproximation extends D3Visualization { generator: { id: "" }, options: { interpolate: false, - spacing: 5, clustering: "quadtree", - radiusMultiplier: 1.0, - gridRows: 8, - threshold: 0, - epsilon: 10, - minPts: 2, - bandwidth: 20, - thresholds: 5, }, icon: "bi bi-bounding-box-circles", w: 8, @@ -30,145 +30,217 @@ export default class BoundaryApproximation extends D3Visualization { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("MapCoordinates"), + options: () => getGeneratorOptions(["MapCoordinates"]), }, "options.interpolate": { type: "switch", label: "Interpolate edges", }, - "options.spacing": { - type: "range", - label: "Interpolation spacing", - options: { min: 1, max: 100 }, - }, "options.clustering": { type: "select", - label: "Clustering method", - options: ["quadtree", "dbscan", "density"], - }, - "options.radiusMultiplier": { - type: "range", - label: "Cluster radius multiplier", - options: { min: 0.1, max: 10.0, step: 0.1 }, - }, - "options.gridRows": { - type: "range", - label: "Grid rows", - options: { min: 1, max: 100 }, - }, - "options.threshold": { - type: "range", - label: "Contour threshold", - options: { min: 0, max: 50 }, - }, - "options.epsilon": { - type: "range", - label: "DBSCAN epsilon", - options: { min: 1, max: 100 }, - }, - "options.minPts": { - type: "range", - label: "DBSCAN minimum points", - options: { min: 1, max: 100 }, - }, - "options.bandwidth": { - type: "range", - label: "Contour density bandwidth", - options: { min: 0, max: 100 }, - }, - "options.thresholds": { - type: "range", - label: "Contour density thresholds", - options: { min: 1, max: 50 }, + label: "Method", + options: [ + { label: "quadtree grid", value: "quadtree" }, + { label: "dbscan clustering", value: "dbscan" }, + { label: "kernel density estimation", value: "density" }, + { label: "voronoi area", value: "voronoi" }, + { label: "k-nearest neighbour", value: "knn" }, + { label: "Delaunay edge length", value: "delaunay" }, + { label: "Gaussian KDE", value: "gaussian" }, + ], }, }; - static previewData = [ - [ - [276.4694937085933, 210.96513455773257], - [231.44104269965737, 212.1801872596352], - ], - [ - [279.8532649869678, 233.57060329542853], - [258.5205158524833, 232.82647918776243], - ], - [ - [258.5205158524833, 232.82647918776243], - [241.73193780364903, 233.5652548110095], - ], - [ - [354.0914634285864, 159.91399739642264], - [344.64352898661446, 156.26693179627298], - ], - [ - [354.0914634285864, 159.91399739642264], - [355.3915585448194, 160.5893335465575], - ], - [ - [298.0951585828476, 253.41146322242082], - [296.1249795167421, 253.9237691377335], - ], - ]; constructor(root, config) { super(root, config, { top: 40, right: 40, bottom: 40, left: 40 }); this.draw = { grid: true, - clusters: true, + circles: true, }; this.interpolate = config.options.interpolate || false; - this.spacing = config.options.spacing || 5; + this.spacing = 5; this.clustering = config.options.clustering || "quadtree"; - this.radiusMultiplier = config.options.radiusMultiplier || 1.0; + this.radiusMultiplier = 1.0; // contour - this.gridRows = config.options.gridRows || 8; - this.threshold = config.options.threshold || 0; + this.gridRows = 8; + this.threshold = 0; + + // knn + this.knnK = 5; + + // Gaussian KDE + this.kdeBandwidth = 30; // dbscan - this.epsilon = config.options.epsilon || 10; - this.minPts = config.options.minPts || 2; + this.epsilon = 10; + this.minPts = 2; // densitiy estimation - this.bandwidth = config.options.bandwidth || 10; - this.thresholds = config.options.thresholds || 5; - } - - async fetch() { - return await d3.json("/data/edges.json"); + this.bandwidth = 10; + this.thresholds = 5; } async init() { - const data = await this.fetch(); - this.render(data); - - this.controls.append([ - { - type: "switch", - label: "Grid", - value: this.draw.grid, - onchange: () => { - this.draw.grid = !this.draw.grid; - this.render(this.data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); + + if (this.clustering !== "density") + this.controls.append([ + { + type: "switch", + label: "Grid", + value: this.draw.grid, + onchange: () => { + this.draw.grid = !this.draw.grid; + this.rerender(); + }, + }, + { + type: "switch", + label: "Circles", + value: this.draw.circles, + onchange: () => { + this.draw.circles = !this.draw.circles; + this.rerender(); + }, + }, + ]); + + if (this.interpolate) + this.controls.append([ + { + type: "range", + label: "Interpolation spacing", + value: this.spacing, + options: { min: 1, max: 100 }, + onchange: (event) => { + this.spacing = event.target.value; + this.rerender(); + }, + }, + ]); + + if (this.clustering !== "density") + this.controls.append([ + { + type: "range", + label: "Circle radius multiplier", + value: this.radiusMultiplier, + options: { min: 0.1, max: 10.0, step: 0.1 }, + onchange: (event) => { + this.radiusMultiplier = event.target.value; + this.rerender(); + }, + }, + { + type: "range", + label: "Grid rows", + value: this.gridRows, + options: { min: 1, max: 100 }, + onchange: (event) => { + this.gridRows = event.target.value; + this.rerender(); + }, }, - }, - { - type: "switch", - label: "Clusters", - value: this.draw.clusters, - onchange: () => { - this.draw.clusters = !this.draw.clusters; - this.render(this.data); + { + type: "range", + label: "Contour threshold", + value: this.threshold, + options: { min: 0, max: 50 }, + onchange: (event) => { + this.threshold = event.target.value; + this.rerender(); + }, }, - }, - ]); + ]); + + if (this.clustering === "dbscan") + this.controls.append([ + { + type: "range", + label: "DBSCAN epsilon", + value: this.epsilon, + options: { min: 1, max: 100 }, + onchange: (event) => { + this.epsilon = event.target.value; + this.rerender(); + }, + }, + { + type: "range", + label: "DBSCAN minimum points", + value: this.minPts, + options: { min: 1, max: 100 }, + onchange: (event) => { + this.minPts = event.target.value; + this.rerender(); + }, + }, + ]); + + if (this.clustering === "density") + this.controls.append([ + { + type: "range", + label: "Contour density bandwidth", + value: this.bandwidth, + options: { min: 0, max: 100 }, + onchange: (event) => { + this.bandwidth = event.target.value; + this.rerender(); + }, + }, + { + type: "range", + label: "Contour density thresholds", + value: this.thresholds, + options: { min: 1, max: 50 }, + onchange: (event) => { + this.thresholds = event.target.value; + this.rerender(); + }, + }, + ]); + + if (this.clustering === "knn") + this.controls.append([ + { + type: "range", + label: "kNN neighbours (k)", + value: this.knnK, + options: { min: 1, max: 20 }, + onchange: (event) => { + this.knnK = +event.target.value; + this.rerender(); + }, + }, + ]); + + if (this.clustering === "gaussian") + this.controls.append([ + { + type: "range", + label: "Gaussian bandwidth", + value: this.kdeBandwidth, + options: { min: 1, max: 200 }, + onchange: (event) => { + this.kdeBandwidth = +event.target.value; + this.rerender(); + }, + }, + ]); } render(data) { this.clear(); - const dataPoints = this.getDataPoints(data); + const dataPoints = this.interpolateEdges(data); // Create the horizontal and vertical scales const xScale = d3 @@ -227,14 +299,22 @@ export default class BoundaryApproximation extends D3Visualization { } // Calculate clusters - const clusters = - this.clustering === "quadtree" - ? this.quadtreeClustering(points, rows, cols, cellSize) - : this.dbscanClustering(dataPoints, points); + const clusterPoints = this.calculateClusters( + dataPoints, + points, + rows, + cols, + cellSize, + ); // Get a value for each grid cell. The values are the distance // from the cell center to the nearest cluster boundary. - const values = this.getCellValues(clusters, rows, cols, cellSize); + const values = this.calculateCellValues( + clusterPoints, + rows, + cols, + cellSize, + ); const contours = d3 .contours() @@ -246,13 +326,13 @@ export default class BoundaryApproximation extends D3Visualization { const path = d3.geoPath(projection); this.drawPath("boundary", contours(values), path); - // Draw clusters - if (this.draw.clusters) { + // Draw circles + if (this.draw.circles) { this.drawCircles( "cluster", - clusters, + clusterPoints, "none", - (d) => this.radiusMultiplier * d[2].length, + (d) => this.radiusMultiplier * d[2], "teal", ).attr("opacity", 0.4); } @@ -269,13 +349,13 @@ export default class BoundaryApproximation extends D3Visualization { this.data = data; } - getDataPoints(edges) { + interpolateEdges(edges) { const points = []; if (this.interpolate) { for (const edge of edges) { - const dx = edge[1][0] - edge[0][0]; - const dy = edge[1][1] - edge[0][1]; + const dx = edge[1].x - edge[0].x; + const dy = edge[1].y - edge[0].y; const length = Math.sqrt(dx * dx + dy * dy); // Number of segments based on spacing @@ -284,133 +364,38 @@ export default class BoundaryApproximation extends D3Visualization { for (let i = 0; i <= steps; i++) { const t = i / steps; points.push({ - x: edge[0][0] + t * dx, - y: edge[0][1] + t * dy, + x: edge[0].x + t * dx, + y: edge[0].y + t * dy, }); } } } else { for (const edge of edges) { - points.push({ x: edge[0][0], y: edge[0][1] }); - points.push({ x: edge[1][0], y: edge[1][1] }); + points.push(...edge); } } return points; } - searchInTree(quadtree, xmin, ymin, xmax, ymax) { - const results = []; - - quadtree.visit((node, x1, y1, x2, y2) => { - if (!node.length) { - do { - let d = node.data; - if (d[0] >= xmin && d[0] < xmax && d[1] >= ymin && d[1] < ymax) { - results.push(d); - } - } while ((node = node.next)); - } - return x1 >= xmax || y1 >= ymax || x2 < xmin || y2 < ymin; - }); - - return results; - } - - quadtreeClustering(points, rows, cols, cellSize) { - const tree = d3.quadtree(points); - const clusters = []; - - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - const found = this.searchInTree( - tree, - x * cellSize, - y * cellSize, - x * cellSize + cellSize, - y * cellSize + cellSize, - ); - - for (const f of found) { - f.push(found); - clusters.push(f); - } - } - } - - return clusters; - } - - dbscan(points, epsilon, minPts) { - const labels = new Array(points.length).fill(undefined); - let clusterId = 0; - - function euclideanDist(a, b) { - const dx = a[0] - b[0]; - const dy = a[1] - b[1]; - return Math.sqrt(dx * dx + dy * dy); + calculateClusters(dataPoints, points, rows, cols, cellSize) { + switch (this.clustering) { + case "quadtree": + return quadtreeClustering(points, rows, cols, cellSize); + case "dbscan": + return dbscanClustering(dataPoints, points, this.epsilon, this.minPts); + case "voronoi": + return voronoiClustering(points, this.width, this.height); + case "knn": + return knnClustering(points, this.knnK); + case "delaunay": + return delaunayClustering(points); + case "gaussian": + return gaussianKdeClustering(points, this.kdeBandwidth); } - - function rangeQuery(idx) { - return points.reduce((neighbors, point, i) => { - if (euclideanDist(points[idx], point) <= epsilon) neighbors.push(i); - return neighbors; - }, []); - } - - for (let i = 0; i < points.length; i++) { - if (labels[i] !== undefined) continue; - - const neighbors = rangeQuery(i); - - if (neighbors.length < minPts) { - labels[i] = -1; // noise - continue; - } - - labels[i] = clusterId; - - const seeds = neighbors.filter((n) => n !== i); - - for (let j = 0; j < seeds.length; j++) { - const s = seeds[j]; - - if (labels[s] === -1) labels[s] = clusterId; - if (labels[s] !== undefined) continue; - - labels[s] = clusterId; - - const newNeighbors = rangeQuery(s); - if (newNeighbors.length >= minPts) { - seeds.push( - ...newNeighbors.filter((n) => !seeds.includes(n) && n !== s), - ); - } - } - - clusterId++; - } - - return labels; - } - - dbscanClustering(data, points) { - const labels = this.dbscan(points, this.epsilon, this.minPts); - const groups = d3.group(data, (_, i) => labels[i]); - groups.delete(-1); - - const clusters = []; - - points.forEach((p, i) => { - const found = groups.get(labels[i]); - p.push(found || p); - clusters.push(p); - }); - - return clusters; } - getCellValues(clusters, rows, cols, cellSize) { + calculateCellValues(points, rows, cols, cellSize) { const values = []; for (let y = 0; y < rows; y++) { @@ -421,10 +406,10 @@ export default class BoundaryApproximation extends D3Visualization { let value = -Infinity; - for (const c of clusters) { + for (const p of points) { // Get distance from cell center to cluster boundary - const radius = this.radiusMultiplier * c[2].length; - const distance = radius - Math.hypot(px - c[0], py - c[1]); + const radius = this.radiusMultiplier * p[2]; + const distance = radius - Math.hypot(px - p[0], py - p[1]); if (distance > value) value = distance; } diff --git a/src/main/resources/static/js/widgets/charts/HighlightText.js b/src/main/resources/static/js/widgets/charts/HighlightText.js index 2688e981..e871abe7 100644 --- a/src/main/resources/static/js/widgets/charts/HighlightText.js +++ b/src/main/resources/static/js/widgets/charts/HighlightText.js @@ -1,10 +1,7 @@ -import { getData } from "../../api/data.api.js"; -import ControlsHandler from "../../pages/view/toolbar/ControlsHandler.js"; -import ExportHandler from "../../pages/view/toolbar/ExportHandler.js"; -import state from "../../pages/view/utils/viewState.js"; import { getGeneratorOptions } from "../../pages/editor/utils/editorActions.js"; +import WidgetInterface from "../WidgetInterface.js"; -export default class HighlightText { +export default class HighlightText extends WidgetInterface { static defaultConfig = { type: "HighlightText", title: "Highlight Text", @@ -22,58 +19,15 @@ export default class HighlightText { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("TextFormatting"), + options: () => getGeneratorOptions(["TextFormatting"]), }, }; - static previewData = { - spans: [ - { - text: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr", - style: "text-decoration: underline 2px #00618f;", - }, - { - text: ", sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. ", - }, - { - text: "Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", - style: "text-decoration: underline 2px #3a4856;", - }, - { - text: " Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. ", - }, - { - text: "Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", - style: "text-decoration: underline 2px #9eadbd;", - }, - ], - }; constructor(root, config) { - this.root = d3.select(root); - this.config = config; - - this.setTitle(this.config.title); + super(root, config); this.tooltip = d3.select(".dv-chart-tooltip"); this.div = this.root.select(".dv-chart-area").append("div"); - this.data = null; - - this.filter = {}; - this.controls = new ControlsHandler(this); - this.exports = new ExportHandler(this); - } - - setTitle(title) { - this.root.select(".dv-toolbar-title").attr("title", title).text(title); - } - - async fetch() { - const { pipeline, generator, type } = this.config; - - return await getData(pipeline, generator.id, type, { - corpus: state.corpusFilter.filter, - chart: this.filter, - }); } clear() { @@ -86,14 +40,17 @@ export default class HighlightText { } async init() { - const data = await this.fetch(); - this.render(data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); this.filter = { hide: [], }; this.controls.append( - data.datasets.map(({ name }) => { + data[0].datasets.map(({ name }) => { return { type: "switch", label: name.split(".").slice(-2).join("."), @@ -104,7 +61,7 @@ export default class HighlightText { } else { this.filter.hide.push(name); } - this.fetch().then((data) => this.render(data)); + this.rerender(true); }, }; }), diff --git a/src/main/resources/static/js/widgets/charts/LineChart.js b/src/main/resources/static/js/widgets/charts/LineChart.js index 15f2da7e..7d132306 100644 --- a/src/main/resources/static/js/widgets/charts/LineChart.js +++ b/src/main/resources/static/js/widgets/charts/LineChart.js @@ -23,7 +23,7 @@ export default class LineChart extends D3Visualization { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("MapCoordinates"), + options: () => getGeneratorOptions(["MapCoordinates"]), }, "options.points": { type: "switch", @@ -40,38 +40,6 @@ export default class LineChart extends D3Visualization { ], }, }; - static previewData = [ - { - name: "Dataset", - color: "#00618f", - coordinates: [ - { - y: 5, - x: 0, - }, - { - y: 20, - x: 20, - }, - { - y: 10, - x: 40, - }, - { - y: 40, - x: 60, - }, - { - y: 5, - x: 80, - }, - { - y: 60, - x: 100, - }, - ], - }, - ]; constructor(root, config) { super(root, config, { top: 20, right: 30, bottom: 30, left: 40 }); @@ -81,14 +49,17 @@ export default class LineChart extends D3Visualization { } async init() { - const data = await this.fetch(); - this.render(data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); this.filter = { hide: [], }; this.controls.append( - data.map(({ name }) => { + data[0].map(({ name }) => { return { type: "switch", label: name, @@ -99,7 +70,7 @@ export default class LineChart extends D3Visualization { } else { this.filter.hide.push(name); } - this.fetch().then((data) => this.render(data)); + this.rerender(true); }, }; }), diff --git a/src/main/resources/static/js/widgets/charts/MedialAxis.js b/src/main/resources/static/js/widgets/charts/MedialAxis.js index 2e678b36..7fbf567c 100644 --- a/src/main/resources/static/js/widgets/charts/MedialAxis.js +++ b/src/main/resources/static/js/widgets/charts/MedialAxis.js @@ -19,71 +19,9 @@ export default class MedialAxis extends D3Visualization { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("MapCoordinates"), + options: () => getGeneratorOptions(["MapCoordinates"]), }, }; - static previewData = [ - { x: 10, y: 10 }, - { x: 12, y: 10 }, - { x: 14, y: 10 }, - { x: 16, y: 10 }, - { x: 18, y: 10 }, - { x: 20, y: 10 }, - { x: 22, y: 10 }, - { x: 24, y: 10 }, - { x: 26, y: 10 }, - { x: 28, y: 10 }, - { x: 30, y: 10 }, - { x: 32, y: 10 }, - { x: 34, y: 10 }, - { x: 36, y: 10 }, - { x: 38, y: 10 }, - { x: 40, y: 10 }, - { x: 40, y: 12 }, - { x: 40, y: 14 }, - { x: 40, y: 16 }, - { x: 40, y: 18 }, - { x: 40, y: 20 }, - { x: 38, y: 20 }, - { x: 36, y: 20 }, - { x: 34, y: 20 }, - { x: 32, y: 20 }, - { x: 30, y: 20 }, - { x: 28, y: 20 }, - { x: 26, y: 20 }, - { x: 24, y: 20 }, - { x: 22, y: 20 }, - { x: 20, y: 20 }, - { x: 20, y: 22 }, - { x: 20, y: 24 }, - { x: 20, y: 26 }, - { x: 20, y: 28 }, - { x: 20, y: 30 }, - { x: 20, y: 32 }, - { x: 20, y: 34 }, - { x: 20, y: 36 }, - { x: 20, y: 38 }, - { x: 20, y: 40 }, - { x: 18, y: 40 }, - { x: 16, y: 40 }, - { x: 14, y: 40 }, - { x: 12, y: 40 }, - { x: 10, y: 40 }, - { x: 10, y: 12 }, - { x: 10, y: 14 }, - { x: 10, y: 16 }, - { x: 10, y: 18 }, - { x: 10, y: 38 }, - { x: 10, y: 36 }, - { x: 10, y: 34 }, - { x: 10, y: 32 }, - { x: 10, y: 30 }, - { x: 10, y: 28 }, - { x: 10, y: 26 }, - { x: 10, y: 24 }, - { x: 10, y: 22 }, - { x: 10, y: 20 }, - ]; constructor(root, config) { super(root, config, { top: 40, right: 40, bottom: 40, left: 40 }); @@ -98,8 +36,11 @@ export default class MedialAxis extends D3Visualization { } async init() { - const data = await this.fetch(); - this.render(data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); this.controls.append([ { @@ -108,7 +49,7 @@ export default class MedialAxis extends D3Visualization { value: this.draw.boundary, onchange: () => { this.draw.boundary = !this.draw.boundary; - this.render(this.data); + this.rerender(); }, }, { @@ -117,7 +58,7 @@ export default class MedialAxis extends D3Visualization { value: this.draw.triangles, onchange: () => { this.draw.triangles = !this.draw.triangles; - this.render(this.data); + this.rerender(); }, }, { @@ -126,7 +67,7 @@ export default class MedialAxis extends D3Visualization { value: this.draw.circles, onchange: () => { this.draw.circles = !this.draw.circles; - this.render(this.data); + this.rerender(); }, }, { @@ -135,7 +76,7 @@ export default class MedialAxis extends D3Visualization { value: this.draw.centers, onchange: () => { this.draw.centers = !this.draw.centers; - this.render(this.data); + this.rerender(); }, }, { @@ -144,7 +85,7 @@ export default class MedialAxis extends D3Visualization { value: this.draw.voronoi, onchange: () => { this.draw.voronoi = !this.draw.voronoi; - this.render(this.data); + this.rerender(); }, }, ]); diff --git a/src/main/resources/static/js/widgets/charts/NetworkGraph.js b/src/main/resources/static/js/widgets/charts/NetworkGraph.js index 6bbad835..ff0aec31 100644 --- a/src/main/resources/static/js/widgets/charts/NetworkGraph.js +++ b/src/main/resources/static/js/widgets/charts/NetworkGraph.js @@ -19,52 +19,20 @@ export default class NetworkGraph extends D3Visualization { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("MapCoordinates"), + options: () => getGeneratorOptions(["MapCoordinates"]), }, }; - static previewData = { - nodes: [ - { - id: 1, - name: "A", - color: "#00618f", - }, - { - id: 2, - name: "B", - color: "#00618f", - }, - { - id: 3, - name: "C", - color: "#00618f", - }, - ], - links: [ - { - source: 1, - target: 2, - color: "#9eadbd", - }, - { - source: 1, - target: 3, - color: "#9eadbd", - }, - ], - }; constructor(root, config) { super(root, config, { top: 20, right: 20, bottom: 20, left: 20 }); } - async fetch() { - return await d3.json("/data/network.json"); - } - async init() { - const data = await this.fetch(); - this.render(data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); } render(data) { diff --git a/src/main/resources/static/js/widgets/charts/PieChart.js b/src/main/resources/static/js/widgets/charts/PieChart.js index b73ae2d4..0581a9ae 100644 --- a/src/main/resources/static/js/widgets/charts/PieChart.js +++ b/src/main/resources/static/js/widgets/charts/PieChart.js @@ -22,7 +22,7 @@ export default class PieChart extends D3Visualization { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("CategoryNumber"), + options: () => getGeneratorOptions(["CategoryNumber"]), }, "options.hole": { type: "range", @@ -38,23 +38,6 @@ export default class PieChart extends D3Visualization { label: "Show legend", }, }; - static previewData = [ - { - label: "Label 1", - value: 140, - color: "#00618f", - }, - { - label: "Label 2", - value: 73, - color: "#3a4856", - }, - { - label: "Label 3", - value: 56, - color: "#9eadbd", - }, - ]; constructor(root, config) { super(root, config, { top: 10, right: 10, bottom: 10, left: 10 }); @@ -64,26 +47,39 @@ export default class PieChart extends D3Visualization { } async init() { - const data = await this.fetch(); - this.render(data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); - const min = d3.min(data.map((d) => d.value)); - const max = d3.max(data.map((d) => d.value)); + const max = d3.max(data[0].map((d) => d.value)); this.filter = { - min, - max, + min: 0, + max: max, + limit: 100 }; this.controls.append([ { type: "rangedouble", label: "Range", value: [this.filter.min, this.filter.max], - options: { min, max }, + options: { min: 0, max: max }, onchange: (min, max) => { this.filter.min = min; this.filter.max = max; - this.fetch().then((data) => this.render(data)); + this.rerender(true); + }, + }, + { + type: "number", + label: "Limit", + value: this.filter.limit, + options: { min: 0, max: 10000 }, + onchange: (event) => { + this.filter.limit = event.target.value; + this.rerender(true); }, }, ]); diff --git a/src/main/resources/static/js/widgets/charts/ScrollTable.js b/src/main/resources/static/js/widgets/charts/ScrollTable.js index d97fb398..e0552615 100644 --- a/src/main/resources/static/js/widgets/charts/ScrollTable.js +++ b/src/main/resources/static/js/widgets/charts/ScrollTable.js @@ -1,8 +1,7 @@ -import ControlsHandler from "../../pages/view/toolbar/ControlsHandler.js"; -import ExportHandler from "../../pages/view/toolbar/ExportHandler.js"; import { getGeneratorOptions } from "../../pages/editor/utils/editorActions.js"; +import WidgetInterface from "../WidgetInterface.js"; -export default class ScrollTable { +export default class ScrollTable extends WidgetInterface { static defaultConfig = { type: "ScrollTable", title: "Table", @@ -22,39 +21,18 @@ export default class ScrollTable { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("CategoryNumber"), + options: () => getGeneratorOptions(["CategoryNumber", "MapCoordinates"]), }, "options.numbers": { type: "switch", label: "Row numbers", }, }; - static previewData = [ - ["Heading 1", "Heading 2", "Heading 3"], - ["Cell 4", "Cell 5", "Cell 6"], - ["Cell 7", "Cell 8", "Cell 9"], - ["Cell 10", "Cell 11", "Cell 12"], - ["Cell 13", "Cell 14", "Cell 15"], - ["Cell 16", "Cell 17", "Cell 18"], - ["Cell 19", "Cell 20", "Cell 21"], - ["Cell 22", "Cell 23", "Cell 24"], - ["Cell 25", "Cell 26", "Cell 27"], - ["Cell 28", "Cell 29", "Cell 30"], - ["Cell 31", "Cell 32", "Cell 33"], - ]; constructor(root, config) { - this.root = d3.select(root); - this.config = config; - - this.setTitle(this.config.title); + super(root, config); this.div = this.root.select(".dv-chart-area").append("div"); - this.data = null; - - this.filter = {}; - this.controls = new ControlsHandler(this); - this.exports = new ExportHandler(this); this.numbers = config.options.numbers || true; } @@ -63,10 +41,6 @@ export default class ScrollTable { this.root.select(".dv-toolbar-title").attr("title", title).text(title); } - async fetch() { - return await d3.json("/data/table.json"); - } - clear() { this.div.selectAll("*").remove(); this.div @@ -76,11 +50,16 @@ export default class ScrollTable { } async init() { - const data = await this.fetch(); - this.render(data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); + + const values = Object.values(data[0][0]); this.filter = { - sort: data[0][0], + sort: values[0], desc: true, }; this.controls.append([ @@ -88,10 +67,10 @@ export default class ScrollTable { type: "select", label: "Sort by", value: this.filter.sort, - options: data[0], + options: values, onchange: (event) => { this.filter.sort = event.target.value; - this.fetch().then((data) => this.render(data)); + this.rerender(true); }, }, { @@ -100,7 +79,7 @@ export default class ScrollTable { value: this.filter.desc, onchange: (event) => { this.filter.desc = event.target.checked; - this.fetch().then((data) => this.render(data)); + this.rerender(true); }, }, ]); @@ -109,9 +88,11 @@ export default class ScrollTable { render(data) { this.clear(); - let rows = data; + const keys = Object.keys(data[0]); + + let rows = data.map((row) => keys.map((k) => row[k] || "")); if (this.numbers) { - rows = data.map((row, i) => [i, ...row]); + rows = rows.map((row, i) => [i, ...row]); rows[0][0] = "#"; } diff --git a/src/main/resources/static/js/widgets/charts/SimpleMap.js b/src/main/resources/static/js/widgets/charts/SimpleMap.js index da2e65be..5690da8f 100644 --- a/src/main/resources/static/js/widgets/charts/SimpleMap.js +++ b/src/main/resources/static/js/widgets/charts/SimpleMap.js @@ -21,29 +21,13 @@ export default class SimpleMap extends D3Visualization { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("MapCoordinates"), + options: () => getGeneratorOptions(["MapCoordinates"]), }, "options.worldColor": { type: "color", label: "World color", }, }; - static previewData = [ - { - type: "Feature", - properties: { - label: "London - New York", - color: "#00618f", - }, - geometry: { - type: "LineString", - coordinates: [ - [0.1278, 51.5074], - [-74.0059, 40.7128], - ], - }, - }, - ]; constructor(root, config) { super(root, config, { top: 0, right: 0, bottom: 0, left: 0 }); @@ -51,13 +35,12 @@ export default class SimpleMap extends D3Visualization { this.worldColor = config.options.worldColor || "#b8b8b8"; } - async fetch() { - return await d3.json("/data/features.geojson"); - } - async init() { - const data = await this.fetch(); - this.render(data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); } render(data) { diff --git a/src/main/resources/static/js/widgets/charts/VoronoiDiagram.js b/src/main/resources/static/js/widgets/charts/VoronoiDiagram.js index cd3befc9..6dc14354 100644 --- a/src/main/resources/static/js/widgets/charts/VoronoiDiagram.js +++ b/src/main/resources/static/js/widgets/charts/VoronoiDiagram.js @@ -6,7 +6,11 @@ export default class VoronoiDiagram extends D3Visualization { type: "VoronoiDiagram", title: "Voronoi Diagram", generator: { id: "" }, - options: {}, + options: { + min: -1, + max: 1, + step: 0.5, + }, icon: "bi bi-columns", w: 8, h: 6, @@ -19,51 +23,45 @@ export default class VoronoiDiagram extends D3Visualization { "generator.id": { type: "select", label: "Generator", - options: () => getGeneratorOptions("MapCoordinates"), + options: () => getGeneratorOptions(["MapCoordinates"]), }, - }; - static previewData = [ - { - x: 10, - y: 10, - cell: "#00618f", - fill: "#00618f", - stroke: "#555555", - label: "Cell 1", - abs: 0.5, + "options.min": { + type: "number", + label: "Boundary min", + options: {}, }, - { - x: 12, - y: 32, - cell: "#3a4856", - fill: "#3a4856", - stroke: "#555555", - label: "Cell 7", - abs: 0.2, + "options.max": { + type: "number", + label: "Boundary max", + options: {}, }, - { - x: 23, - y: 23, - cell: "#9eadbd", - fill: "#9eadbd", - stroke: "#555555", - label: "Cell 10", - abs: 0.1, + "options.step": { + type: "range", + label: "Boundary step length", + options: { min: 0.1, max: 10, step: 0.1 }, }, - ]; + }; constructor(root, config) { super(root, config, { top: 40, right: 40, bottom: 40, left: 40 }); + this.min = config.options.min || -1; + this.max = config.options.max || 1; + this.step = config.options.step || 0.5; + this.draw = { points: true, polygons: false, + boundary: false, }; } async init() { - const data = await this.fetch(); - this.render(data); + const { data, meta } = await this.fetch(); + this.render(data[0]); + + this.exports.init(meta.total > 1); + this.pagination.init(meta.ids); this.controls.append([ { @@ -72,7 +70,7 @@ export default class VoronoiDiagram extends D3Visualization { value: this.draw.points, onchange: () => { this.draw.points = !this.draw.points; - this.render(this.data); + this.rerender(); }, }, { @@ -81,7 +79,16 @@ export default class VoronoiDiagram extends D3Visualization { value: this.draw.polygons, onchange: () => { this.draw.polygons = !this.draw.polygons; - this.render(this.data); + this.rerender(); + }, + }, + { + type: "switch", + label: "Boundary points", + value: this.draw.boundary, + onchange: () => { + this.draw.boundary = !this.draw.boundary; + this.rerender(); }, }, ]); @@ -90,16 +97,27 @@ export default class VoronoiDiagram extends D3Visualization { render(data) { this.clear(); + const boundaryPoints = this.draw.boundary + ? this.generateBoundaryPoints({ + minX: this.min, + minY: this.min, + maxX: this.max, + maxY: this.max, + step: this.step, + }) + : []; + const dataPoints = [...boundaryPoints, ...data]; + // Create the horizontal and vertical scales const xScale = d3 .scaleLinear() .range([0, this.width]) - .domain(this.domain(data, (d) => d.x)); + .domain(this.domain(dataPoints, (d) => d.x)); const yScale = d3 .scaleLinear() .range([this.height, 0]) - .domain(this.domain(data, (d) => d.y)); + .domain(this.domain(dataPoints, (d) => d.y)); const { area, zoom } = this.createAxisZoom([1, 40], { bottom: xScale, @@ -109,7 +127,7 @@ export default class VoronoiDiagram extends D3Visualization { }); // Calculate voronoi - const points = data.map((d) => [xScale(d.x), yScale(d.y)]); + const points = dataPoints.map((d) => [xScale(d.x), yScale(d.y)]); const delaunay = d3.Delaunay.from(points); const voronoi = delaunay.voronoi([0, 0, this.width, this.height]); @@ -129,20 +147,20 @@ export default class VoronoiDiagram extends D3Visualization { if (this.draw.polygons) { area .selectAll("path.polygon") - .data(data) + .data(dataPoints) .join("path") .attr("class", "polygon") .attr("d", (d, i) => renderPolygon(i, d.abs)) - .attr("fill", (_, i) => data[i].fill) + .attr("fill", (_, i) => dataPoints[i].fill) .attr("opacity", 0.7) - .attr("stroke", (_, i) => data[i].stroke) + .attr("stroke", (_, i) => dataPoints[i].stroke) .attr("stroke-width", 2); } // Add the cells area .selectAll("path.cell") - .data(data) + .data(dataPoints) .join("path") .attr("class", (d) => (d.label ? "cell labeled" : "cell")) .attr("d", (_, i) => voronoi.renderCell(i)) @@ -153,7 +171,7 @@ export default class VoronoiDiagram extends D3Visualization { if (this.draw.points) { area .selectAll("circle") - .data(data) + .data(dataPoints) .join("circle") .attr("cx", (d) => xScale(d.x)) .attr("cy", (d) => yScale(d.y)) @@ -172,4 +190,27 @@ export default class VoronoiDiagram extends D3Visualization { // Cache rendered data this.data = data; } + + generateBoundaryPoints({ minX, maxX, minY, maxY, step }) { + const points = []; + + const stepsX = Math.round((maxX - minX) / step); + const stepsY = Math.round((maxY - minY) / step); + + // Top & bottom edges (include corners) + for (let i = 0; i <= stepsX; i++) { + const x = minX + i * step; + points.push({ x, y: minY, fill: "#aaaaaa" }); // bottom + points.push({ x, y: maxY, fill: "#aaaaaa" }); // top + } + + // Left & right edges (exclude corners) + for (let i = 1; i < stepsY; i++) { + const y = minY + i * step; + points.push({ x: minX, y, fill: "#aaaaaa" }); // left + points.push({ x: maxX, y, fill: "#aaaaaa" }); // right + } + + return points; + } } diff --git a/src/main/resources/static/js/widgets/static/StaticIFrame.js b/src/main/resources/static/js/widgets/static/StaticIFrame.js index 438c45cb..16f73a30 100644 --- a/src/main/resources/static/js/widgets/static/StaticIFrame.js +++ b/src/main/resources/static/js/widgets/static/StaticIFrame.js @@ -3,7 +3,9 @@ export default class StaticIFrame { type: "StaticIFrame", title: "Inline Frame", src: "https://example.com/", - options: {}, + options: { + border: true, + }, icon: "bi bi-window", w: 8, h: 6, @@ -17,6 +19,10 @@ export default class StaticIFrame { type: "text", label: "URL", }, + "options.border": { + type: "switch", + label: "Border", + }, }; constructor(root, config) { @@ -24,6 +30,7 @@ export default class StaticIFrame { this.config = config; this.src = config.src || ""; + this.border = config.options.border || true; } clear() { @@ -41,7 +48,8 @@ export default class StaticIFrame { .append("iframe") .attr("src", data) .attr("width", "100%") - .attr("height", "100%"); + .attr("height", "100%") + .classed("dv-bordered", this.border); // Disable pointer events for dragging in editor if (d3.select(".dv-chart-tooltip").empty()) { @@ -50,4 +58,8 @@ export default class StaticIFrame { this.root.classed("overflow-hidden", true); } + + rerender() { + this.render(this.src); + } } diff --git a/src/main/resources/static/js/widgets/static/StaticImage.js b/src/main/resources/static/js/widgets/static/StaticImage.js index 1e88ec5f..d3f033e7 100644 --- a/src/main/resources/static/js/widgets/static/StaticImage.js +++ b/src/main/resources/static/js/widgets/static/StaticImage.js @@ -43,4 +43,8 @@ export default class StaticImage { .attr("width", "100%") .attr("height", "100%"); } + + rerender() { + this.render(this.src); + } } diff --git a/src/main/resources/static/js/widgets/static/StaticText.js b/src/main/resources/static/js/widgets/static/StaticText.js index 1e265718..13aeca1d 100644 --- a/src/main/resources/static/js/widgets/static/StaticText.js +++ b/src/main/resources/static/js/widgets/static/StaticText.js @@ -54,7 +54,7 @@ export default class StaticText { this.root = d3.select(root); this.config = config; - this.text = config.src || ""; + this.src = config.src || ""; this.align = config.options.align || "start"; this.size = config.options.size || "5"; this.weight = config.options.weight || "normal"; @@ -67,7 +67,7 @@ export default class StaticText { } init() { - this.render(this.text); + this.render(this.src); } render(data) { @@ -81,4 +81,8 @@ export default class StaticText { ) .text(data); } + + rerender() { + this.render(this.src); + } } diff --git a/src/main/resources/static/js/widgets/static/StaticVideo.js b/src/main/resources/static/js/widgets/static/StaticVideo.js index c973452f..dd194d91 100644 --- a/src/main/resources/static/js/widgets/static/StaticVideo.js +++ b/src/main/resources/static/js/widgets/static/StaticVideo.js @@ -35,8 +35,8 @@ export default class StaticVideo { this.config = config; this.src = config.src || ""; - this.controls = config.options.controls || true; - this.autoplay = config.options.autoplay || false; + this.controls = config.options.controls || false; + this.autoplay = config.options.autoplay || true; } clear() { @@ -60,4 +60,8 @@ export default class StaticVideo { this.root.classed("overflow-hidden", true); } + + rerender() { + this.render(this.src); + } } diff --git a/src/main/resources/static/packages/floating-ui-core-1.7.5/package/LICENSE b/src/main/resources/static/packages/floating-ui-core-1.7.5/package/LICENSE new file mode 100644 index 00000000..639cdc6c --- /dev/null +++ b/src/main/resources/static/packages/floating-ui-core-1.7.5/package/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/main/resources/static/packages/floating-ui-core-1.7.5/package/README.md b/src/main/resources/static/packages/floating-ui-core-1.7.5/package/README.md new file mode 100644 index 00000000..c4b69b2e --- /dev/null +++ b/src/main/resources/static/packages/floating-ui-core-1.7.5/package/README.md @@ -0,0 +1,4 @@ +# @floating-ui/core + +This is the platform-agnostic core of Floating UI, exposing the main +`computePosition` function but no platform interface logic. diff --git a/src/main/resources/static/packages/floating-ui-core-1.7.5/package/dist/floating-ui.core.umd.js b/src/main/resources/static/packages/floating-ui-core-1.7.5/package/dist/floating-ui.core.umd.js new file mode 100644 index 00000000..31df0442 --- /dev/null +++ b/src/main/resources/static/packages/floating-ui-core-1.7.5/package/dist/floating-ui.core.umd.js @@ -0,0 +1,1203 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : + typeof define === 'function' && define.amd ? define(['exports'], factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.FloatingUICore = {})); +})(this, (function (exports) { 'use strict'; + + /** + * Custom positioning reference element. + * @see https://floating-ui.com/docs/virtual-elements + */ + + const sides = ['top', 'right', 'bottom', 'left']; + const alignments = ['start', 'end']; + const placements = /*#__PURE__*/sides.reduce((acc, side) => acc.concat(side, side + "-" + alignments[0], side + "-" + alignments[1]), []); + const min = Math.min; + const max = Math.max; + const oppositeSideMap = { + left: 'right', + right: 'left', + bottom: 'top', + top: 'bottom' + }; + function clamp(start, value, end) { + return max(start, min(value, end)); + } + function evaluate(value, param) { + return typeof value === 'function' ? value(param) : value; + } + function getSide(placement) { + return placement.split('-')[0]; + } + function getAlignment(placement) { + return placement.split('-')[1]; + } + function getOppositeAxis(axis) { + return axis === 'x' ? 'y' : 'x'; + } + function getAxisLength(axis) { + return axis === 'y' ? 'height' : 'width'; + } + function getSideAxis(placement) { + const firstChar = placement[0]; + return firstChar === 't' || firstChar === 'b' ? 'y' : 'x'; + } + function getAlignmentAxis(placement) { + return getOppositeAxis(getSideAxis(placement)); + } + function getAlignmentSides(placement, rects, rtl) { + if (rtl === void 0) { + rtl = false; + } + const alignment = getAlignment(placement); + const alignmentAxis = getAlignmentAxis(placement); + const length = getAxisLength(alignmentAxis); + let mainAlignmentSide = alignmentAxis === 'x' ? alignment === (rtl ? 'end' : 'start') ? 'right' : 'left' : alignment === 'start' ? 'bottom' : 'top'; + if (rects.reference[length] > rects.floating[length]) { + mainAlignmentSide = getOppositePlacement(mainAlignmentSide); + } + return [mainAlignmentSide, getOppositePlacement(mainAlignmentSide)]; + } + function getExpandedPlacements(placement) { + const oppositePlacement = getOppositePlacement(placement); + return [getOppositeAlignmentPlacement(placement), oppositePlacement, getOppositeAlignmentPlacement(oppositePlacement)]; + } + function getOppositeAlignmentPlacement(placement) { + return placement.includes('start') ? placement.replace('start', 'end') : placement.replace('end', 'start'); + } + const lrPlacement = ['left', 'right']; + const rlPlacement = ['right', 'left']; + const tbPlacement = ['top', 'bottom']; + const btPlacement = ['bottom', 'top']; + function getSideList(side, isStart, rtl) { + switch (side) { + case 'top': + case 'bottom': + if (rtl) return isStart ? rlPlacement : lrPlacement; + return isStart ? lrPlacement : rlPlacement; + case 'left': + case 'right': + return isStart ? tbPlacement : btPlacement; + default: + return []; + } + } + function getOppositeAxisPlacements(placement, flipAlignment, direction, rtl) { + const alignment = getAlignment(placement); + let list = getSideList(getSide(placement), direction === 'start', rtl); + if (alignment) { + list = list.map(side => side + "-" + alignment); + if (flipAlignment) { + list = list.concat(list.map(getOppositeAlignmentPlacement)); + } + } + return list; + } + function getOppositePlacement(placement) { + const side = getSide(placement); + return oppositeSideMap[side] + placement.slice(side.length); + } + function expandPaddingObject(padding) { + return { + top: 0, + right: 0, + bottom: 0, + left: 0, + ...padding + }; + } + function getPaddingObject(padding) { + return typeof padding !== 'number' ? expandPaddingObject(padding) : { + top: padding, + right: padding, + bottom: padding, + left: padding + }; + } + function rectToClientRect(rect) { + const { + x, + y, + width, + height + } = rect; + return { + width, + height, + top: y, + left: x, + right: x + width, + bottom: y + height, + x, + y + }; + } + + function computeCoordsFromPlacement(_ref, placement, rtl) { + let { + reference, + floating + } = _ref; + const sideAxis = getSideAxis(placement); + const alignmentAxis = getAlignmentAxis(placement); + const alignLength = getAxisLength(alignmentAxis); + const side = getSide(placement); + const isVertical = sideAxis === 'y'; + const commonX = reference.x + reference.width / 2 - floating.width / 2; + const commonY = reference.y + reference.height / 2 - floating.height / 2; + const commonAlign = reference[alignLength] / 2 - floating[alignLength] / 2; + let coords; + switch (side) { + case 'top': + coords = { + x: commonX, + y: reference.y - floating.height + }; + break; + case 'bottom': + coords = { + x: commonX, + y: reference.y + reference.height + }; + break; + case 'right': + coords = { + x: reference.x + reference.width, + y: commonY + }; + break; + case 'left': + coords = { + x: reference.x - floating.width, + y: commonY + }; + break; + default: + coords = { + x: reference.x, + y: reference.y + }; + } + switch (getAlignment(placement)) { + case 'start': + coords[alignmentAxis] -= commonAlign * (rtl && isVertical ? -1 : 1); + break; + case 'end': + coords[alignmentAxis] += commonAlign * (rtl && isVertical ? -1 : 1); + break; + } + return coords; + } + + /** + * Resolves with an object of overflow side offsets that determine how much the + * element is overflowing a given clipping boundary on each side. + * - positive = overflowing the boundary by that number of pixels + * - negative = how many pixels left before it will overflow + * - 0 = lies flush with the boundary + * @see https://floating-ui.com/docs/detectOverflow + */ + async function detectOverflow(state, options) { + var _await$platform$isEle; + if (options === void 0) { + options = {}; + } + const { + x, + y, + platform, + rects, + elements, + strategy + } = state; + const { + boundary = 'clippingAncestors', + rootBoundary = 'viewport', + elementContext = 'floating', + altBoundary = false, + padding = 0 + } = evaluate(options, state); + const paddingObject = getPaddingObject(padding); + const altContext = elementContext === 'floating' ? 'reference' : 'floating'; + const element = elements[altBoundary ? altContext : elementContext]; + const clippingClientRect = rectToClientRect(await platform.getClippingRect({ + element: ((_await$platform$isEle = await (platform.isElement == null ? void 0 : platform.isElement(element))) != null ? _await$platform$isEle : true) ? element : element.contextElement || (await (platform.getDocumentElement == null ? void 0 : platform.getDocumentElement(elements.floating))), + boundary, + rootBoundary, + strategy + })); + const rect = elementContext === 'floating' ? { + x, + y, + width: rects.floating.width, + height: rects.floating.height + } : rects.reference; + const offsetParent = await (platform.getOffsetParent == null ? void 0 : platform.getOffsetParent(elements.floating)); + const offsetScale = (await (platform.isElement == null ? void 0 : platform.isElement(offsetParent))) ? (await (platform.getScale == null ? void 0 : platform.getScale(offsetParent))) || { + x: 1, + y: 1 + } : { + x: 1, + y: 1 + }; + const elementClientRect = rectToClientRect(platform.convertOffsetParentRelativeRectToViewportRelativeRect ? await platform.convertOffsetParentRelativeRectToViewportRelativeRect({ + elements, + rect, + offsetParent, + strategy + }) : rect); + return { + top: (clippingClientRect.top - elementClientRect.top + paddingObject.top) / offsetScale.y, + bottom: (elementClientRect.bottom - clippingClientRect.bottom + paddingObject.bottom) / offsetScale.y, + left: (clippingClientRect.left - elementClientRect.left + paddingObject.left) / offsetScale.x, + right: (elementClientRect.right - clippingClientRect.right + paddingObject.right) / offsetScale.x + }; + } + + // Maximum number of resets that can occur before bailing to avoid infinite reset loops. + const MAX_RESET_COUNT = 50; + + /** + * Computes the `x` and `y` coordinates that will place the floating element + * next to a given reference element. + * + * This export does not have any `platform` interface logic. You will need to + * write one for the platform you are using Floating UI with. + */ + const computePosition = async (reference, floating, config) => { + const { + placement = 'bottom', + strategy = 'absolute', + middleware = [], + platform + } = config; + const platformWithDetectOverflow = platform.detectOverflow ? platform : { + ...platform, + detectOverflow + }; + const rtl = await (platform.isRTL == null ? void 0 : platform.isRTL(floating)); + let rects = await platform.getElementRects({ + reference, + floating, + strategy + }); + let { + x, + y + } = computeCoordsFromPlacement(rects, placement, rtl); + let statefulPlacement = placement; + let resetCount = 0; + const middlewareData = {}; + for (let i = 0; i < middleware.length; i++) { + const currentMiddleware = middleware[i]; + if (!currentMiddleware) { + continue; + } + const { + name, + fn + } = currentMiddleware; + const { + x: nextX, + y: nextY, + data, + reset + } = await fn({ + x, + y, + initialPlacement: placement, + placement: statefulPlacement, + strategy, + middlewareData, + rects, + platform: platformWithDetectOverflow, + elements: { + reference, + floating + } + }); + x = nextX != null ? nextX : x; + y = nextY != null ? nextY : y; + middlewareData[name] = { + ...middlewareData[name], + ...data + }; + if (reset && resetCount < MAX_RESET_COUNT) { + resetCount++; + if (typeof reset === 'object') { + if (reset.placement) { + statefulPlacement = reset.placement; + } + if (reset.rects) { + rects = reset.rects === true ? await platform.getElementRects({ + reference, + floating, + strategy + }) : reset.rects; + } + ({ + x, + y + } = computeCoordsFromPlacement(rects, statefulPlacement, rtl)); + } + i = -1; + } + } + return { + x, + y, + placement: statefulPlacement, + strategy, + middlewareData + }; + }; + + /** + * Provides data to position an inner element of the floating element so that it + * appears centered to the reference element. + * @see https://floating-ui.com/docs/arrow + */ + const arrow = options => ({ + name: 'arrow', + options, + async fn(state) { + const { + x, + y, + placement, + rects, + platform, + elements, + middlewareData + } = state; + // Since `element` is required, we don't Partial<> the type. + const { + element, + padding = 0 + } = evaluate(options, state) || {}; + if (element == null) { + return {}; + } + const paddingObject = getPaddingObject(padding); + const coords = { + x, + y + }; + const axis = getAlignmentAxis(placement); + const length = getAxisLength(axis); + const arrowDimensions = await platform.getDimensions(element); + const isYAxis = axis === 'y'; + const minProp = isYAxis ? 'top' : 'left'; + const maxProp = isYAxis ? 'bottom' : 'right'; + const clientProp = isYAxis ? 'clientHeight' : 'clientWidth'; + const endDiff = rects.reference[length] + rects.reference[axis] - coords[axis] - rects.floating[length]; + const startDiff = coords[axis] - rects.reference[axis]; + const arrowOffsetParent = await (platform.getOffsetParent == null ? void 0 : platform.getOffsetParent(element)); + let clientSize = arrowOffsetParent ? arrowOffsetParent[clientProp] : 0; + + // DOM platform can return `window` as the `offsetParent`. + if (!clientSize || !(await (platform.isElement == null ? void 0 : platform.isElement(arrowOffsetParent)))) { + clientSize = elements.floating[clientProp] || rects.floating[length]; + } + const centerToReference = endDiff / 2 - startDiff / 2; + + // If the padding is large enough that it causes the arrow to no longer be + // centered, modify the padding so that it is centered. + const largestPossiblePadding = clientSize / 2 - arrowDimensions[length] / 2 - 1; + const minPadding = min(paddingObject[minProp], largestPossiblePadding); + const maxPadding = min(paddingObject[maxProp], largestPossiblePadding); + + // Make sure the arrow doesn't overflow the floating element if the center + // point is outside the floating element's bounds. + const min$1 = minPadding; + const max = clientSize - arrowDimensions[length] - maxPadding; + const center = clientSize / 2 - arrowDimensions[length] / 2 + centerToReference; + const offset = clamp(min$1, center, max); + + // If the reference is small enough that the arrow's padding causes it to + // to point to nothing for an aligned placement, adjust the offset of the + // floating element itself. To ensure `shift()` continues to take action, + // a single reset is performed when this is true. + const shouldAddOffset = !middlewareData.arrow && getAlignment(placement) != null && center !== offset && rects.reference[length] / 2 - (center < min$1 ? minPadding : maxPadding) - arrowDimensions[length] / 2 < 0; + const alignmentOffset = shouldAddOffset ? center < min$1 ? center - min$1 : center - max : 0; + return { + [axis]: coords[axis] + alignmentOffset, + data: { + [axis]: offset, + centerOffset: center - offset - alignmentOffset, + ...(shouldAddOffset && { + alignmentOffset + }) + }, + reset: shouldAddOffset + }; + } + }); + + function getPlacementList(alignment, autoAlignment, allowedPlacements) { + const allowedPlacementsSortedByAlignment = alignment ? [...allowedPlacements.filter(placement => getAlignment(placement) === alignment), ...allowedPlacements.filter(placement => getAlignment(placement) !== alignment)] : allowedPlacements.filter(placement => getSide(placement) === placement); + return allowedPlacementsSortedByAlignment.filter(placement => { + if (alignment) { + return getAlignment(placement) === alignment || (autoAlignment ? getOppositeAlignmentPlacement(placement) !== placement : false); + } + return true; + }); + } + /** + * Optimizes the visibility of the floating element by choosing the placement + * that has the most space available automatically, without needing to specify a + * preferred placement. Alternative to `flip`. + * @see https://floating-ui.com/docs/autoPlacement + */ + const autoPlacement = function (options) { + if (options === void 0) { + options = {}; + } + return { + name: 'autoPlacement', + options, + async fn(state) { + var _middlewareData$autoP, _middlewareData$autoP2, _placementsThatFitOnE; + const { + rects, + middlewareData, + placement, + platform, + elements + } = state; + const { + crossAxis = false, + alignment, + allowedPlacements = placements, + autoAlignment = true, + ...detectOverflowOptions + } = evaluate(options, state); + const placements$1 = alignment !== undefined || allowedPlacements === placements ? getPlacementList(alignment || null, autoAlignment, allowedPlacements) : allowedPlacements; + const overflow = await platform.detectOverflow(state, detectOverflowOptions); + const currentIndex = ((_middlewareData$autoP = middlewareData.autoPlacement) == null ? void 0 : _middlewareData$autoP.index) || 0; + const currentPlacement = placements$1[currentIndex]; + if (currentPlacement == null) { + return {}; + } + const alignmentSides = getAlignmentSides(currentPlacement, rects, await (platform.isRTL == null ? void 0 : platform.isRTL(elements.floating))); + + // Make `computeCoords` start from the right place. + if (placement !== currentPlacement) { + return { + reset: { + placement: placements$1[0] + } + }; + } + const currentOverflows = [overflow[getSide(currentPlacement)], overflow[alignmentSides[0]], overflow[alignmentSides[1]]]; + const allOverflows = [...(((_middlewareData$autoP2 = middlewareData.autoPlacement) == null ? void 0 : _middlewareData$autoP2.overflows) || []), { + placement: currentPlacement, + overflows: currentOverflows + }]; + const nextPlacement = placements$1[currentIndex + 1]; + + // There are more placements to check. + if (nextPlacement) { + return { + data: { + index: currentIndex + 1, + overflows: allOverflows + }, + reset: { + placement: nextPlacement + } + }; + } + const placementsSortedByMostSpace = allOverflows.map(d => { + const alignment = getAlignment(d.placement); + return [d.placement, alignment && crossAxis ? + // Check along the mainAxis and main crossAxis side. + d.overflows.slice(0, 2).reduce((acc, v) => acc + v, 0) : + // Check only the mainAxis. + d.overflows[0], d.overflows]; + }).sort((a, b) => a[1] - b[1]); + const placementsThatFitOnEachSide = placementsSortedByMostSpace.filter(d => d[2].slice(0, + // Aligned placements should not check their opposite crossAxis + // side. + getAlignment(d[0]) ? 2 : 3).every(v => v <= 0)); + const resetPlacement = ((_placementsThatFitOnE = placementsThatFitOnEachSide[0]) == null ? void 0 : _placementsThatFitOnE[0]) || placementsSortedByMostSpace[0][0]; + if (resetPlacement !== placement) { + return { + data: { + index: currentIndex + 1, + overflows: allOverflows + }, + reset: { + placement: resetPlacement + } + }; + } + return {}; + } + }; + }; + + /** + * Optimizes the visibility of the floating element by flipping the `placement` + * in order to keep it in view when the preferred placement(s) will overflow the + * clipping boundary. Alternative to `autoPlacement`. + * @see https://floating-ui.com/docs/flip + */ + const flip = function (options) { + if (options === void 0) { + options = {}; + } + return { + name: 'flip', + options, + async fn(state) { + var _middlewareData$arrow, _middlewareData$flip; + const { + placement, + middlewareData, + rects, + initialPlacement, + platform, + elements + } = state; + const { + mainAxis: checkMainAxis = true, + crossAxis: checkCrossAxis = true, + fallbackPlacements: specifiedFallbackPlacements, + fallbackStrategy = 'bestFit', + fallbackAxisSideDirection = 'none', + flipAlignment = true, + ...detectOverflowOptions + } = evaluate(options, state); + + // If a reset by the arrow was caused due to an alignment offset being + // added, we should skip any logic now since `flip()` has already done its + // work. + // https://github.com/floating-ui/floating-ui/issues/2549#issuecomment-1719601643 + if ((_middlewareData$arrow = middlewareData.arrow) != null && _middlewareData$arrow.alignmentOffset) { + return {}; + } + const side = getSide(placement); + const initialSideAxis = getSideAxis(initialPlacement); + const isBasePlacement = getSide(initialPlacement) === initialPlacement; + const rtl = await (platform.isRTL == null ? void 0 : platform.isRTL(elements.floating)); + const fallbackPlacements = specifiedFallbackPlacements || (isBasePlacement || !flipAlignment ? [getOppositePlacement(initialPlacement)] : getExpandedPlacements(initialPlacement)); + const hasFallbackAxisSideDirection = fallbackAxisSideDirection !== 'none'; + if (!specifiedFallbackPlacements && hasFallbackAxisSideDirection) { + fallbackPlacements.push(...getOppositeAxisPlacements(initialPlacement, flipAlignment, fallbackAxisSideDirection, rtl)); + } + const placements = [initialPlacement, ...fallbackPlacements]; + const overflow = await platform.detectOverflow(state, detectOverflowOptions); + const overflows = []; + let overflowsData = ((_middlewareData$flip = middlewareData.flip) == null ? void 0 : _middlewareData$flip.overflows) || []; + if (checkMainAxis) { + overflows.push(overflow[side]); + } + if (checkCrossAxis) { + const sides = getAlignmentSides(placement, rects, rtl); + overflows.push(overflow[sides[0]], overflow[sides[1]]); + } + overflowsData = [...overflowsData, { + placement, + overflows + }]; + + // One or more sides is overflowing. + if (!overflows.every(side => side <= 0)) { + var _middlewareData$flip2, _overflowsData$filter; + const nextIndex = (((_middlewareData$flip2 = middlewareData.flip) == null ? void 0 : _middlewareData$flip2.index) || 0) + 1; + const nextPlacement = placements[nextIndex]; + if (nextPlacement) { + const ignoreCrossAxisOverflow = checkCrossAxis === 'alignment' ? initialSideAxis !== getSideAxis(nextPlacement) : false; + if (!ignoreCrossAxisOverflow || + // We leave the current main axis only if every placement on that axis + // overflows the main axis. + overflowsData.every(d => getSideAxis(d.placement) === initialSideAxis ? d.overflows[0] > 0 : true)) { + // Try next placement and re-run the lifecycle. + return { + data: { + index: nextIndex, + overflows: overflowsData + }, + reset: { + placement: nextPlacement + } + }; + } + } + + // First, find the candidates that fit on the mainAxis side of overflow, + // then find the placement that fits the best on the main crossAxis side. + let resetPlacement = (_overflowsData$filter = overflowsData.filter(d => d.overflows[0] <= 0).sort((a, b) => a.overflows[1] - b.overflows[1])[0]) == null ? void 0 : _overflowsData$filter.placement; + + // Otherwise fallback. + if (!resetPlacement) { + switch (fallbackStrategy) { + case 'bestFit': + { + var _overflowsData$filter2; + const placement = (_overflowsData$filter2 = overflowsData.filter(d => { + if (hasFallbackAxisSideDirection) { + const currentSideAxis = getSideAxis(d.placement); + return currentSideAxis === initialSideAxis || + // Create a bias to the `y` side axis due to horizontal + // reading directions favoring greater width. + currentSideAxis === 'y'; + } + return true; + }).map(d => [d.placement, d.overflows.filter(overflow => overflow > 0).reduce((acc, overflow) => acc + overflow, 0)]).sort((a, b) => a[1] - b[1])[0]) == null ? void 0 : _overflowsData$filter2[0]; + if (placement) { + resetPlacement = placement; + } + break; + } + case 'initialPlacement': + resetPlacement = initialPlacement; + break; + } + } + if (placement !== resetPlacement) { + return { + reset: { + placement: resetPlacement + } + }; + } + } + return {}; + } + }; + }; + + function getSideOffsets(overflow, rect) { + return { + top: overflow.top - rect.height, + right: overflow.right - rect.width, + bottom: overflow.bottom - rect.height, + left: overflow.left - rect.width + }; + } + function isAnySideFullyClipped(overflow) { + return sides.some(side => overflow[side] >= 0); + } + /** + * Provides data to hide the floating element in applicable situations, such as + * when it is not in the same clipping context as the reference element. + * @see https://floating-ui.com/docs/hide + */ + const hide = function (options) { + if (options === void 0) { + options = {}; + } + return { + name: 'hide', + options, + async fn(state) { + const { + rects, + platform + } = state; + const { + strategy = 'referenceHidden', + ...detectOverflowOptions + } = evaluate(options, state); + switch (strategy) { + case 'referenceHidden': + { + const overflow = await platform.detectOverflow(state, { + ...detectOverflowOptions, + elementContext: 'reference' + }); + const offsets = getSideOffsets(overflow, rects.reference); + return { + data: { + referenceHiddenOffsets: offsets, + referenceHidden: isAnySideFullyClipped(offsets) + } + }; + } + case 'escaped': + { + const overflow = await platform.detectOverflow(state, { + ...detectOverflowOptions, + altBoundary: true + }); + const offsets = getSideOffsets(overflow, rects.floating); + return { + data: { + escapedOffsets: offsets, + escaped: isAnySideFullyClipped(offsets) + } + }; + } + default: + { + return {}; + } + } + } + }; + }; + + function getBoundingRect(rects) { + const minX = min(...rects.map(rect => rect.left)); + const minY = min(...rects.map(rect => rect.top)); + const maxX = max(...rects.map(rect => rect.right)); + const maxY = max(...rects.map(rect => rect.bottom)); + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + }; + } + function getRectsByLine(rects) { + const sortedRects = rects.slice().sort((a, b) => a.y - b.y); + const groups = []; + let prevRect = null; + for (let i = 0; i < sortedRects.length; i++) { + const rect = sortedRects[i]; + if (!prevRect || rect.y - prevRect.y > prevRect.height / 2) { + groups.push([rect]); + } else { + groups[groups.length - 1].push(rect); + } + prevRect = rect; + } + return groups.map(rect => rectToClientRect(getBoundingRect(rect))); + } + /** + * Provides improved positioning for inline reference elements that can span + * over multiple lines, such as hyperlinks or range selections. + * @see https://floating-ui.com/docs/inline + */ + const inline = function (options) { + if (options === void 0) { + options = {}; + } + return { + name: 'inline', + options, + async fn(state) { + const { + placement, + elements, + rects, + platform, + strategy + } = state; + // A MouseEvent's client{X,Y} coords can be up to 2 pixels off a + // ClientRect's bounds, despite the event listener being triggered. A + // padding of 2 seems to handle this issue. + const { + padding = 2, + x, + y + } = evaluate(options, state); + const nativeClientRects = Array.from((await (platform.getClientRects == null ? void 0 : platform.getClientRects(elements.reference))) || []); + const clientRects = getRectsByLine(nativeClientRects); + const fallback = rectToClientRect(getBoundingRect(nativeClientRects)); + const paddingObject = getPaddingObject(padding); + function getBoundingClientRect() { + // There are two rects and they are disjoined. + if (clientRects.length === 2 && clientRects[0].left > clientRects[1].right && x != null && y != null) { + // Find the first rect in which the point is fully inside. + return clientRects.find(rect => x > rect.left - paddingObject.left && x < rect.right + paddingObject.right && y > rect.top - paddingObject.top && y < rect.bottom + paddingObject.bottom) || fallback; + } + + // There are 2 or more connected rects. + if (clientRects.length >= 2) { + if (getSideAxis(placement) === 'y') { + const firstRect = clientRects[0]; + const lastRect = clientRects[clientRects.length - 1]; + const isTop = getSide(placement) === 'top'; + const top = firstRect.top; + const bottom = lastRect.bottom; + const left = isTop ? firstRect.left : lastRect.left; + const right = isTop ? firstRect.right : lastRect.right; + const width = right - left; + const height = bottom - top; + return { + top, + bottom, + left, + right, + width, + height, + x: left, + y: top + }; + } + const isLeftSide = getSide(placement) === 'left'; + const maxRight = max(...clientRects.map(rect => rect.right)); + const minLeft = min(...clientRects.map(rect => rect.left)); + const measureRects = clientRects.filter(rect => isLeftSide ? rect.left === minLeft : rect.right === maxRight); + const top = measureRects[0].top; + const bottom = measureRects[measureRects.length - 1].bottom; + const left = minLeft; + const right = maxRight; + const width = right - left; + const height = bottom - top; + return { + top, + bottom, + left, + right, + width, + height, + x: left, + y: top + }; + } + return fallback; + } + const resetRects = await platform.getElementRects({ + reference: { + getBoundingClientRect + }, + floating: elements.floating, + strategy + }); + if (rects.reference.x !== resetRects.reference.x || rects.reference.y !== resetRects.reference.y || rects.reference.width !== resetRects.reference.width || rects.reference.height !== resetRects.reference.height) { + return { + reset: { + rects: resetRects + } + }; + } + return {}; + } + }; + }; + + const originSides = /*#__PURE__*/new Set(['left', 'top']); + + // For type backwards-compatibility, the `OffsetOptions` type was also + // Derivable. + + async function convertValueToCoords(state, options) { + const { + placement, + platform, + elements + } = state; + const rtl = await (platform.isRTL == null ? void 0 : platform.isRTL(elements.floating)); + const side = getSide(placement); + const alignment = getAlignment(placement); + const isVertical = getSideAxis(placement) === 'y'; + const mainAxisMulti = originSides.has(side) ? -1 : 1; + const crossAxisMulti = rtl && isVertical ? -1 : 1; + const rawValue = evaluate(options, state); + + // eslint-disable-next-line prefer-const + let { + mainAxis, + crossAxis, + alignmentAxis + } = typeof rawValue === 'number' ? { + mainAxis: rawValue, + crossAxis: 0, + alignmentAxis: null + } : { + mainAxis: rawValue.mainAxis || 0, + crossAxis: rawValue.crossAxis || 0, + alignmentAxis: rawValue.alignmentAxis + }; + if (alignment && typeof alignmentAxis === 'number') { + crossAxis = alignment === 'end' ? alignmentAxis * -1 : alignmentAxis; + } + return isVertical ? { + x: crossAxis * crossAxisMulti, + y: mainAxis * mainAxisMulti + } : { + x: mainAxis * mainAxisMulti, + y: crossAxis * crossAxisMulti + }; + } + + /** + * Modifies the placement by translating the floating element along the + * specified axes. + * A number (shorthand for `mainAxis` or distance), or an axes configuration + * object may be passed. + * @see https://floating-ui.com/docs/offset + */ + const offset = function (options) { + if (options === void 0) { + options = 0; + } + return { + name: 'offset', + options, + async fn(state) { + var _middlewareData$offse, _middlewareData$arrow; + const { + x, + y, + placement, + middlewareData + } = state; + const diffCoords = await convertValueToCoords(state, options); + + // If the placement is the same and the arrow caused an alignment offset + // then we don't need to change the positioning coordinates. + if (placement === ((_middlewareData$offse = middlewareData.offset) == null ? void 0 : _middlewareData$offse.placement) && (_middlewareData$arrow = middlewareData.arrow) != null && _middlewareData$arrow.alignmentOffset) { + return {}; + } + return { + x: x + diffCoords.x, + y: y + diffCoords.y, + data: { + ...diffCoords, + placement + } + }; + } + }; + }; + + /** + * Optimizes the visibility of the floating element by shifting it in order to + * keep it in view when it will overflow the clipping boundary. + * @see https://floating-ui.com/docs/shift + */ + const shift = function (options) { + if (options === void 0) { + options = {}; + } + return { + name: 'shift', + options, + async fn(state) { + const { + x, + y, + placement, + platform + } = state; + const { + mainAxis: checkMainAxis = true, + crossAxis: checkCrossAxis = false, + limiter = { + fn: _ref => { + let { + x, + y + } = _ref; + return { + x, + y + }; + } + }, + ...detectOverflowOptions + } = evaluate(options, state); + const coords = { + x, + y + }; + const overflow = await platform.detectOverflow(state, detectOverflowOptions); + const crossAxis = getSideAxis(getSide(placement)); + const mainAxis = getOppositeAxis(crossAxis); + let mainAxisCoord = coords[mainAxis]; + let crossAxisCoord = coords[crossAxis]; + if (checkMainAxis) { + const minSide = mainAxis === 'y' ? 'top' : 'left'; + const maxSide = mainAxis === 'y' ? 'bottom' : 'right'; + const min = mainAxisCoord + overflow[minSide]; + const max = mainAxisCoord - overflow[maxSide]; + mainAxisCoord = clamp(min, mainAxisCoord, max); + } + if (checkCrossAxis) { + const minSide = crossAxis === 'y' ? 'top' : 'left'; + const maxSide = crossAxis === 'y' ? 'bottom' : 'right'; + const min = crossAxisCoord + overflow[minSide]; + const max = crossAxisCoord - overflow[maxSide]; + crossAxisCoord = clamp(min, crossAxisCoord, max); + } + const limitedCoords = limiter.fn({ + ...state, + [mainAxis]: mainAxisCoord, + [crossAxis]: crossAxisCoord + }); + return { + ...limitedCoords, + data: { + x: limitedCoords.x - x, + y: limitedCoords.y - y, + enabled: { + [mainAxis]: checkMainAxis, + [crossAxis]: checkCrossAxis + } + } + }; + } + }; + }; + /** + * Built-in `limiter` that will stop `shift()` at a certain point. + */ + const limitShift = function (options) { + if (options === void 0) { + options = {}; + } + return { + options, + fn(state) { + const { + x, + y, + placement, + rects, + middlewareData + } = state; + const { + offset = 0, + mainAxis: checkMainAxis = true, + crossAxis: checkCrossAxis = true + } = evaluate(options, state); + const coords = { + x, + y + }; + const crossAxis = getSideAxis(placement); + const mainAxis = getOppositeAxis(crossAxis); + let mainAxisCoord = coords[mainAxis]; + let crossAxisCoord = coords[crossAxis]; + const rawOffset = evaluate(offset, state); + const computedOffset = typeof rawOffset === 'number' ? { + mainAxis: rawOffset, + crossAxis: 0 + } : { + mainAxis: 0, + crossAxis: 0, + ...rawOffset + }; + if (checkMainAxis) { + const len = mainAxis === 'y' ? 'height' : 'width'; + const limitMin = rects.reference[mainAxis] - rects.floating[len] + computedOffset.mainAxis; + const limitMax = rects.reference[mainAxis] + rects.reference[len] - computedOffset.mainAxis; + if (mainAxisCoord < limitMin) { + mainAxisCoord = limitMin; + } else if (mainAxisCoord > limitMax) { + mainAxisCoord = limitMax; + } + } + if (checkCrossAxis) { + var _middlewareData$offse, _middlewareData$offse2; + const len = mainAxis === 'y' ? 'width' : 'height'; + const isOriginSide = originSides.has(getSide(placement)); + const limitMin = rects.reference[crossAxis] - rects.floating[len] + (isOriginSide ? ((_middlewareData$offse = middlewareData.offset) == null ? void 0 : _middlewareData$offse[crossAxis]) || 0 : 0) + (isOriginSide ? 0 : computedOffset.crossAxis); + const limitMax = rects.reference[crossAxis] + rects.reference[len] + (isOriginSide ? 0 : ((_middlewareData$offse2 = middlewareData.offset) == null ? void 0 : _middlewareData$offse2[crossAxis]) || 0) - (isOriginSide ? computedOffset.crossAxis : 0); + if (crossAxisCoord < limitMin) { + crossAxisCoord = limitMin; + } else if (crossAxisCoord > limitMax) { + crossAxisCoord = limitMax; + } + } + return { + [mainAxis]: mainAxisCoord, + [crossAxis]: crossAxisCoord + }; + } + }; + }; + + /** + * Provides data that allows you to change the size of the floating element — + * for instance, prevent it from overflowing the clipping boundary or match the + * width of the reference element. + * @see https://floating-ui.com/docs/size + */ + const size = function (options) { + if (options === void 0) { + options = {}; + } + return { + name: 'size', + options, + async fn(state) { + var _state$middlewareData, _state$middlewareData2; + const { + placement, + rects, + platform, + elements + } = state; + const { + apply = () => {}, + ...detectOverflowOptions + } = evaluate(options, state); + const overflow = await platform.detectOverflow(state, detectOverflowOptions); + const side = getSide(placement); + const alignment = getAlignment(placement); + const isYAxis = getSideAxis(placement) === 'y'; + const { + width, + height + } = rects.floating; + let heightSide; + let widthSide; + if (side === 'top' || side === 'bottom') { + heightSide = side; + widthSide = alignment === ((await (platform.isRTL == null ? void 0 : platform.isRTL(elements.floating))) ? 'start' : 'end') ? 'left' : 'right'; + } else { + widthSide = side; + heightSide = alignment === 'end' ? 'top' : 'bottom'; + } + const maximumClippingHeight = height - overflow.top - overflow.bottom; + const maximumClippingWidth = width - overflow.left - overflow.right; + const overflowAvailableHeight = min(height - overflow[heightSide], maximumClippingHeight); + const overflowAvailableWidth = min(width - overflow[widthSide], maximumClippingWidth); + const noShift = !state.middlewareData.shift; + let availableHeight = overflowAvailableHeight; + let availableWidth = overflowAvailableWidth; + if ((_state$middlewareData = state.middlewareData.shift) != null && _state$middlewareData.enabled.x) { + availableWidth = maximumClippingWidth; + } + if ((_state$middlewareData2 = state.middlewareData.shift) != null && _state$middlewareData2.enabled.y) { + availableHeight = maximumClippingHeight; + } + if (noShift && !alignment) { + const xMin = max(overflow.left, 0); + const xMax = max(overflow.right, 0); + const yMin = max(overflow.top, 0); + const yMax = max(overflow.bottom, 0); + if (isYAxis) { + availableWidth = width - 2 * (xMin !== 0 || xMax !== 0 ? xMin + xMax : max(overflow.left, overflow.right)); + } else { + availableHeight = height - 2 * (yMin !== 0 || yMax !== 0 ? yMin + yMax : max(overflow.top, overflow.bottom)); + } + } + await apply({ + ...state, + availableWidth, + availableHeight + }); + const nextDimensions = await platform.getDimensions(elements.floating); + if (width !== nextDimensions.width || height !== nextDimensions.height) { + return { + reset: { + rects: true + } + }; + } + return {}; + } + }; + }; + + exports.arrow = arrow; + exports.autoPlacement = autoPlacement; + exports.computePosition = computePosition; + exports.detectOverflow = detectOverflow; + exports.flip = flip; + exports.hide = hide; + exports.inline = inline; + exports.limitShift = limitShift; + exports.offset = offset; + exports.rectToClientRect = rectToClientRect; + exports.shift = shift; + exports.size = size; + +})); diff --git a/src/main/resources/static/packages/floating-ui-core-1.7.5/package/dist/floating-ui.core.umd.min.js b/src/main/resources/static/packages/floating-ui-core-1.7.5/package/dist/floating-ui.core.umd.min.js new file mode 100644 index 00000000..d402812d --- /dev/null +++ b/src/main/resources/static/packages/floating-ui-core-1.7.5/package/dist/floating-ui.core.umd.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).FloatingUICore={})}(this,(function(t){"use strict";const e=["top","right","bottom","left"],n=["start","end"],i=e.reduce(((t,e)=>t.concat(e,e+"-"+n[0],e+"-"+n[1])),[]),o=Math.min,r=Math.max,a={left:"right",right:"left",bottom:"top",top:"bottom"};function l(t,e,n){return r(t,o(e,n))}function s(t,e){return"function"==typeof t?t(e):t}function f(t){return t.split("-")[0]}function c(t){return t.split("-")[1]}function m(t){return"x"===t?"y":"x"}function u(t){return"y"===t?"height":"width"}function d(t){const e=t[0];return"t"===e||"b"===e?"y":"x"}function g(t){return m(d(t))}function p(t,e,n){void 0===n&&(n=!1);const i=c(t),o=g(t),r=u(o);let a="x"===o?i===(n?"end":"start")?"right":"left":"start"===i?"bottom":"top";return e.reference[r]>e.floating[r]&&(a=A(a)),[a,A(a)]}function h(t){return t.includes("start")?t.replace("start","end"):t.replace("end","start")}const y=["left","right"],w=["right","left"],x=["top","bottom"],v=["bottom","top"];function b(t,e,n,i){const o=c(t);let r=function(t,e,n){switch(t){case"top":case"bottom":return n?e?w:y:e?y:w;case"left":case"right":return e?x:v;default:return[]}}(f(t),"start"===n,i);return o&&(r=r.map((t=>t+"-"+o)),e&&(r=r.concat(r.map(h)))),r}function A(t){const e=f(t);return a[e]+t.slice(e.length)}function R(t){return"number"!=typeof t?function(t){return{top:0,right:0,bottom:0,left:0,...t}}(t):{top:t,right:t,bottom:t,left:t}}function O(t){const{x:e,y:n,width:i,height:o}=t;return{width:i,height:o,top:n,left:e,right:e+i,bottom:n+o,x:e,y:n}}function P(t,e,n){let{reference:i,floating:o}=t;const r=d(e),a=g(e),l=u(a),s=f(e),m="y"===r,p=i.x+i.width/2-o.width/2,h=i.y+i.height/2-o.height/2,y=i[l]/2-o[l]/2;let w;switch(s){case"top":w={x:p,y:i.y-o.height};break;case"bottom":w={x:p,y:i.y+i.height};break;case"right":w={x:i.x+i.width,y:h};break;case"left":w={x:i.x-o.width,y:h};break;default:w={x:i.x,y:i.y}}switch(c(e)){case"start":w[a]-=y*(n&&m?-1:1);break;case"end":w[a]+=y*(n&&m?-1:1)}return w}async function D(t,e){var n;void 0===e&&(e={});const{x:i,y:o,platform:r,rects:a,elements:l,strategy:f}=t,{boundary:c="clippingAncestors",rootBoundary:m="viewport",elementContext:u="floating",altBoundary:d=!1,padding:g=0}=s(e,t),p=R(g),h=l[d?"floating"===u?"reference":"floating":u],y=O(await r.getClippingRect({element:null==(n=await(null==r.isElement?void 0:r.isElement(h)))||n?h:h.contextElement||await(null==r.getDocumentElement?void 0:r.getDocumentElement(l.floating)),boundary:c,rootBoundary:m,strategy:f})),w="floating"===u?{x:i,y:o,width:a.floating.width,height:a.floating.height}:a.reference,x=await(null==r.getOffsetParent?void 0:r.getOffsetParent(l.floating)),v=await(null==r.isElement?void 0:r.isElement(x))&&await(null==r.getScale?void 0:r.getScale(x))||{x:1,y:1},b=O(r.convertOffsetParentRelativeRectToViewportRelativeRect?await r.convertOffsetParentRelativeRectToViewportRelativeRect({elements:l,rect:w,offsetParent:x,strategy:f}):w);return{top:(y.top-b.top+p.top)/v.y,bottom:(b.bottom-y.bottom+p.bottom)/v.y,left:(y.left-b.left+p.left)/v.x,right:(b.right-y.right+p.right)/v.x}}function T(t,e){return{top:t.top-e.height,right:t.right-e.width,bottom:t.bottom-e.height,left:t.left-e.width}}function E(t){return e.some((e=>t[e]>=0))}function L(t){const e=o(...t.map((t=>t.left))),n=o(...t.map((t=>t.top)));return{x:e,y:n,width:r(...t.map((t=>t.right)))-e,height:r(...t.map((t=>t.bottom)))-n}}const k=new Set(["left","top"]);t.arrow=t=>({name:"arrow",options:t,async fn(e){const{x:n,y:i,placement:r,rects:a,platform:f,elements:m,middlewareData:d}=e,{element:p,padding:h=0}=s(t,e)||{};if(null==p)return{};const y=R(h),w={x:n,y:i},x=g(r),v=u(x),b=await f.getDimensions(p),A="y"===x,O=A?"top":"left",P=A?"bottom":"right",D=A?"clientHeight":"clientWidth",T=a.reference[v]+a.reference[x]-w[x]-a.floating[v],E=w[x]-a.reference[x],L=await(null==f.getOffsetParent?void 0:f.getOffsetParent(p));let k=L?L[D]:0;k&&await(null==f.isElement?void 0:f.isElement(L))||(k=m.floating[D]||a.floating[v]);const C=T/2-E/2,H=k/2-b[v]/2-1,S=o(y[O],H),B=o(y[P],H),F=S,j=k-b[v]-B,z=k/2-b[v]/2+C,M=l(F,z,j),V=!d.arrow&&null!=c(r)&&z!==M&&a.reference[v]/2-(zc(e)===t)),...n.filter((e=>c(e)!==t))]:n.filter((t=>f(t)===t))).filter((n=>!t||c(n)===t||!!e&&h(n)!==n))}(y||null,x,w):w,A=await u.detectOverflow(e,v),R=(null==(n=l.autoPlacement)?void 0:n.index)||0,O=b[R];if(null==O)return{};const P=p(O,a,await(null==u.isRTL?void 0:u.isRTL(d.floating)));if(m!==O)return{reset:{placement:b[0]}};const D=[A[f(O)],A[P[0]],A[P[1]]],T=[...(null==(o=l.autoPlacement)?void 0:o.overflows)||[],{placement:O,overflows:D}],E=b[R+1];if(E)return{data:{index:R+1,overflows:T},reset:{placement:E}};const L=T.map((t=>{const e=c(t.placement);return[t.placement,e&&g?t.overflows.slice(0,2).reduce(((t,e)=>t+e),0):t.overflows[0],t.overflows]})).sort(((t,e)=>t[1]-e[1])),k=(null==(r=L.filter((t=>t[2].slice(0,c(t[0])?2:3).every((t=>t<=0))))[0])?void 0:r[0])||L[0][0];return k!==m?{data:{index:R+1,overflows:T},reset:{placement:k}}:{}}}},t.computePosition=async(t,e,n)=>{const{placement:i="bottom",strategy:o="absolute",middleware:r=[],platform:a}=n,l=a.detectOverflow?a:{...a,detectOverflow:D},s=await(null==a.isRTL?void 0:a.isRTL(e));let f=await a.getElementRects({reference:t,floating:e,strategy:o}),{x:c,y:m}=P(f,i,s),u=i,d=0;const g={};for(let n=0;nt<=0))){var B,F;const t=((null==(B=r.flip)?void 0:B.index)||0)+1,e=k[t];if(e){if(!("alignment"===g&&P!==d(e))||S.every((t=>d(t.placement)!==P||t.overflows[0]>0)))return{data:{index:t,overflows:S},reset:{placement:e}}}let n=null==(F=S.filter((t=>t.overflows[0]<=0)).sort(((t,e)=>t.overflows[1]-e.overflows[1]))[0])?void 0:F.placement;if(!n)switch(w){case"bestFit":{var j;const t=null==(j=S.filter((t=>{if(L){const e=d(t.placement);return e===P||"y"===e}return!0})).map((t=>[t.placement,t.overflows.filter((t=>t>0)).reduce(((t,e)=>t+e),0)])).sort(((t,e)=>t[1]-e[1]))[0])?void 0:j[0];t&&(n=t);break}case"initialPlacement":n=l}if(o!==n)return{reset:{placement:n}}}return{}}}},t.hide=function(t){return void 0===t&&(t={}),{name:"hide",options:t,async fn(e){const{rects:n,platform:i}=e,{strategy:o="referenceHidden",...r}=s(t,e);switch(o){case"referenceHidden":{const t=T(await i.detectOverflow(e,{...r,elementContext:"reference"}),n.reference);return{data:{referenceHiddenOffsets:t,referenceHidden:E(t)}}}case"escaped":{const t=T(await i.detectOverflow(e,{...r,altBoundary:!0}),n.floating);return{data:{escapedOffsets:t,escaped:E(t)}}}default:return{}}}}},t.inline=function(t){return void 0===t&&(t={}),{name:"inline",options:t,async fn(e){const{placement:n,elements:i,rects:a,platform:l,strategy:c}=e,{padding:m=2,x:u,y:g}=s(t,e),p=Array.from(await(null==l.getClientRects?void 0:l.getClientRects(i.reference))||[]),h=function(t){const e=t.slice().sort(((t,e)=>t.y-e.y)),n=[];let i=null;for(let t=0;ti.height/2?n.push([o]):n[n.length-1].push(o),i=o}return n.map((t=>O(L(t))))}(p),y=O(L(p)),w=R(m);const x=await l.getElementRects({reference:{getBoundingClientRect:function(){if(2===h.length&&h[0].left>h[1].right&&null!=u&&null!=g)return h.find((t=>u>t.left-w.left&&ut.top-w.top&&g=2){if("y"===d(n)){const t=h[0],e=h[h.length-1],i="top"===f(n),o=t.top,r=e.bottom,a=i?t.left:e.left,l=i?t.right:e.right;return{top:o,bottom:r,left:a,right:l,width:l-a,height:r-o,x:a,y:o}}const t="left"===f(n),e=r(...h.map((t=>t.right))),i=o(...h.map((t=>t.left))),a=h.filter((n=>t?n.left===i:n.right===e)),l=a[0].top,s=a[a.length-1].bottom;return{top:l,bottom:s,left:i,right:e,width:e-i,height:s-l,x:i,y:l}}return y}},floating:i.floating,strategy:c});return a.reference.x!==x.reference.x||a.reference.y!==x.reference.y||a.reference.width!==x.reference.width||a.reference.height!==x.reference.height?{reset:{rects:x}}:{}}}},t.limitShift=function(t){return void 0===t&&(t={}),{options:t,fn(e){const{x:n,y:i,placement:o,rects:r,middlewareData:a}=e,{offset:l=0,mainAxis:c=!0,crossAxis:u=!0}=s(t,e),g={x:n,y:i},p=d(o),h=m(p);let y=g[h],w=g[p];const x=s(l,e),v="number"==typeof x?{mainAxis:x,crossAxis:0}:{mainAxis:0,crossAxis:0,...x};if(c){const t="y"===h?"height":"width",e=r.reference[h]-r.floating[t]+v.mainAxis,n=r.reference[h]+r.reference[t]-v.mainAxis;yn&&(y=n)}if(u){var b,A;const t="y"===h?"width":"height",e=k.has(f(o)),n=r.reference[p]-r.floating[t]+(e&&(null==(b=a.offset)?void 0:b[p])||0)+(e?0:v.crossAxis),i=r.reference[p]+r.reference[t]+(e?0:(null==(A=a.offset)?void 0:A[p])||0)-(e?v.crossAxis:0);wi&&(w=i)}return{[h]:y,[p]:w}}}},t.offset=function(t){return void 0===t&&(t=0),{name:"offset",options:t,async fn(e){var n,i;const{x:o,y:r,placement:a,middlewareData:l}=e,m=await async function(t,e){const{placement:n,platform:i,elements:o}=t,r=await(null==i.isRTL?void 0:i.isRTL(o.floating)),a=f(n),l=c(n),m="y"===d(n),u=k.has(a)?-1:1,g=r&&m?-1:1,p=s(e,t);let{mainAxis:h,crossAxis:y,alignmentAxis:w}="number"==typeof p?{mainAxis:p,crossAxis:0,alignmentAxis:null}:{mainAxis:p.mainAxis||0,crossAxis:p.crossAxis||0,alignmentAxis:p.alignmentAxis};return l&&"number"==typeof w&&(y="end"===l?-1*w:w),m?{x:y*g,y:h*u}:{x:h*u,y:y*g}}(e,t);return a===(null==(n=l.offset)?void 0:n.placement)&&null!=(i=l.arrow)&&i.alignmentOffset?{}:{x:o+m.x,y:r+m.y,data:{...m,placement:a}}}}},t.rectToClientRect=O,t.shift=function(t){return void 0===t&&(t={}),{name:"shift",options:t,async fn(e){const{x:n,y:i,placement:o,platform:r}=e,{mainAxis:a=!0,crossAxis:c=!1,limiter:u={fn:t=>{let{x:e,y:n}=t;return{x:e,y:n}}},...g}=s(t,e),p={x:n,y:i},h=await r.detectOverflow(e,g),y=d(f(o)),w=m(y);let x=p[w],v=p[y];if(a){const t="y"===w?"bottom":"right";x=l(x+h["y"===w?"top":"left"],x,x-h[t])}if(c){const t="y"===y?"bottom":"right";v=l(v+h["y"===y?"top":"left"],v,v-h[t])}const b=u.fn({...e,[w]:x,[y]:v});return{...b,data:{x:b.x-n,y:b.y-i,enabled:{[w]:a,[y]:c}}}}}},t.size=function(t){return void 0===t&&(t={}),{name:"size",options:t,async fn(e){var n,i;const{placement:a,rects:l,platform:m,elements:u}=e,{apply:g=()=>{},...p}=s(t,e),h=await m.detectOverflow(e,p),y=f(a),w=c(a),x="y"===d(a),{width:v,height:b}=l.floating;let A,R;"top"===y||"bottom"===y?(A=y,R=w===(await(null==m.isRTL?void 0:m.isRTL(u.floating))?"start":"end")?"left":"right"):(R=y,A="end"===w?"top":"bottom");const O=b-h.top-h.bottom,P=v-h.left-h.right,D=o(b-h[A],O),T=o(v-h[R],P),E=!e.middlewareData.shift;let L=D,k=T;if(null!=(n=e.middlewareData.shift)&&n.enabled.x&&(k=P),null!=(i=e.middlewareData.shift)&&i.enabled.y&&(L=O),E&&!w){const t=r(h.left,0),e=r(h.right,0),n=r(h.top,0),i=r(h.bottom,0);x?k=v-2*(0!==t||0!==e?t+e:r(h.left,h.right)):L=b-2*(0!==n||0!==i?n+i:r(h.top,h.bottom))}await g({...e,availableWidth:k,availableHeight:L});const C=await m.getDimensions(u.floating);return v!==C.width||b!==C.height?{reset:{rects:!0}}:{}}}}})); diff --git a/src/main/resources/static/packages/floating-ui-dom-1.7.6/package/LICENSE b/src/main/resources/static/packages/floating-ui-dom-1.7.6/package/LICENSE new file mode 100644 index 00000000..639cdc6c --- /dev/null +++ b/src/main/resources/static/packages/floating-ui-dom-1.7.6/package/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/main/resources/static/packages/floating-ui-dom-1.7.6/package/README.md b/src/main/resources/static/packages/floating-ui-dom-1.7.6/package/README.md new file mode 100644 index 00000000..47ef9272 --- /dev/null +++ b/src/main/resources/static/packages/floating-ui-dom-1.7.6/package/README.md @@ -0,0 +1,4 @@ +# @floating-ui/dom + +This is the library to use Floating UI on the web, wrapping `@floating-ui/core` +with DOM interface logic. diff --git a/src/main/resources/static/packages/floating-ui-dom-1.7.6/package/dist/floating-ui.dom.umd.js b/src/main/resources/static/packages/floating-ui-dom-1.7.6/package/dist/floating-ui.dom.umd.js new file mode 100644 index 00000000..3cf78875 --- /dev/null +++ b/src/main/resources/static/packages/floating-ui-dom-1.7.6/package/dist/floating-ui.dom.umd.js @@ -0,0 +1,975 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@floating-ui/core')) : + typeof define === 'function' && define.amd ? define(['exports', '@floating-ui/core'], factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.FloatingUIDOM = {}, global.FloatingUICore)); +})(this, (function (exports, core) { 'use strict'; + + /** + * Custom positioning reference element. + * @see https://floating-ui.com/docs/virtual-elements + */ + + const min = Math.min; + const max = Math.max; + const round = Math.round; + const floor = Math.floor; + const createCoords = v => ({ + x: v, + y: v + }); + + function hasWindow() { + return typeof window !== 'undefined'; + } + function getNodeName(node) { + if (isNode(node)) { + return (node.nodeName || '').toLowerCase(); + } + // Mocked nodes in testing environments may not be instances of Node. By + // returning `#document` an infinite loop won't occur. + // https://github.com/floating-ui/floating-ui/issues/2317 + return '#document'; + } + function getWindow(node) { + var _node$ownerDocument; + return (node == null || (_node$ownerDocument = node.ownerDocument) == null ? void 0 : _node$ownerDocument.defaultView) || window; + } + function getDocumentElement(node) { + var _ref; + return (_ref = (isNode(node) ? node.ownerDocument : node.document) || window.document) == null ? void 0 : _ref.documentElement; + } + function isNode(value) { + if (!hasWindow()) { + return false; + } + return value instanceof Node || value instanceof getWindow(value).Node; + } + function isElement(value) { + if (!hasWindow()) { + return false; + } + return value instanceof Element || value instanceof getWindow(value).Element; + } + function isHTMLElement(value) { + if (!hasWindow()) { + return false; + } + return value instanceof HTMLElement || value instanceof getWindow(value).HTMLElement; + } + function isShadowRoot(value) { + if (!hasWindow() || typeof ShadowRoot === 'undefined') { + return false; + } + return value instanceof ShadowRoot || value instanceof getWindow(value).ShadowRoot; + } + function isOverflowElement(element) { + const { + overflow, + overflowX, + overflowY, + display + } = getComputedStyle$1(element); + return /auto|scroll|overlay|hidden|clip/.test(overflow + overflowY + overflowX) && display !== 'inline' && display !== 'contents'; + } + function isTableElement(element) { + return /^(table|td|th)$/.test(getNodeName(element)); + } + function isTopLayer(element) { + try { + if (element.matches(':popover-open')) { + return true; + } + } catch (_e) { + // no-op + } + try { + return element.matches(':modal'); + } catch (_e) { + return false; + } + } + const willChangeRe = /transform|translate|scale|rotate|perspective|filter/; + const containRe = /paint|layout|strict|content/; + const isNotNone = value => !!value && value !== 'none'; + let isWebKitValue; + function isContainingBlock(elementOrCss) { + const css = isElement(elementOrCss) ? getComputedStyle$1(elementOrCss) : elementOrCss; + + // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block + // https://drafts.csswg.org/css-transforms-2/#individual-transforms + return isNotNone(css.transform) || isNotNone(css.translate) || isNotNone(css.scale) || isNotNone(css.rotate) || isNotNone(css.perspective) || !isWebKit() && (isNotNone(css.backdropFilter) || isNotNone(css.filter)) || willChangeRe.test(css.willChange || '') || containRe.test(css.contain || ''); + } + function getContainingBlock(element) { + let currentNode = getParentNode(element); + while (isHTMLElement(currentNode) && !isLastTraversableNode(currentNode)) { + if (isContainingBlock(currentNode)) { + return currentNode; + } else if (isTopLayer(currentNode)) { + return null; + } + currentNode = getParentNode(currentNode); + } + return null; + } + function isWebKit() { + if (isWebKitValue == null) { + isWebKitValue = typeof CSS !== 'undefined' && CSS.supports && CSS.supports('-webkit-backdrop-filter', 'none'); + } + return isWebKitValue; + } + function isLastTraversableNode(node) { + return /^(html|body|#document)$/.test(getNodeName(node)); + } + function getComputedStyle$1(element) { + return getWindow(element).getComputedStyle(element); + } + function getNodeScroll(element) { + if (isElement(element)) { + return { + scrollLeft: element.scrollLeft, + scrollTop: element.scrollTop + }; + } + return { + scrollLeft: element.scrollX, + scrollTop: element.scrollY + }; + } + function getParentNode(node) { + if (getNodeName(node) === 'html') { + return node; + } + const result = + // Step into the shadow DOM of the parent of a slotted node. + node.assignedSlot || + // DOM Element detected. + node.parentNode || + // ShadowRoot detected. + isShadowRoot(node) && node.host || + // Fallback. + getDocumentElement(node); + return isShadowRoot(result) ? result.host : result; + } + function getNearestOverflowAncestor(node) { + const parentNode = getParentNode(node); + if (isLastTraversableNode(parentNode)) { + return node.ownerDocument ? node.ownerDocument.body : node.body; + } + if (isHTMLElement(parentNode) && isOverflowElement(parentNode)) { + return parentNode; + } + return getNearestOverflowAncestor(parentNode); + } + function getOverflowAncestors(node, list, traverseIframes) { + var _node$ownerDocument2; + if (list === void 0) { + list = []; + } + if (traverseIframes === void 0) { + traverseIframes = true; + } + const scrollableAncestor = getNearestOverflowAncestor(node); + const isBody = scrollableAncestor === ((_node$ownerDocument2 = node.ownerDocument) == null ? void 0 : _node$ownerDocument2.body); + const win = getWindow(scrollableAncestor); + if (isBody) { + const frameElement = getFrameElement(win); + return list.concat(win, win.visualViewport || [], isOverflowElement(scrollableAncestor) ? scrollableAncestor : [], frameElement && traverseIframes ? getOverflowAncestors(frameElement) : []); + } else { + return list.concat(scrollableAncestor, getOverflowAncestors(scrollableAncestor, [], traverseIframes)); + } + } + function getFrameElement(win) { + return win.parent && Object.getPrototypeOf(win.parent) ? win.frameElement : null; + } + + function getCssDimensions(element) { + const css = getComputedStyle$1(element); + // In testing environments, the `width` and `height` properties are empty + // strings for SVG elements, returning NaN. Fallback to `0` in this case. + let width = parseFloat(css.width) || 0; + let height = parseFloat(css.height) || 0; + const hasOffset = isHTMLElement(element); + const offsetWidth = hasOffset ? element.offsetWidth : width; + const offsetHeight = hasOffset ? element.offsetHeight : height; + const shouldFallback = round(width) !== offsetWidth || round(height) !== offsetHeight; + if (shouldFallback) { + width = offsetWidth; + height = offsetHeight; + } + return { + width, + height, + $: shouldFallback + }; + } + + function unwrapElement(element) { + return !isElement(element) ? element.contextElement : element; + } + + function getScale(element) { + const domElement = unwrapElement(element); + if (!isHTMLElement(domElement)) { + return createCoords(1); + } + const rect = domElement.getBoundingClientRect(); + const { + width, + height, + $ + } = getCssDimensions(domElement); + let x = ($ ? round(rect.width) : rect.width) / width; + let y = ($ ? round(rect.height) : rect.height) / height; + + // 0, NaN, or Infinity should always fallback to 1. + + if (!x || !Number.isFinite(x)) { + x = 1; + } + if (!y || !Number.isFinite(y)) { + y = 1; + } + return { + x, + y + }; + } + + const noOffsets = /*#__PURE__*/createCoords(0); + function getVisualOffsets(element) { + const win = getWindow(element); + if (!isWebKit() || !win.visualViewport) { + return noOffsets; + } + return { + x: win.visualViewport.offsetLeft, + y: win.visualViewport.offsetTop + }; + } + function shouldAddVisualOffsets(element, isFixed, floatingOffsetParent) { + if (isFixed === void 0) { + isFixed = false; + } + if (!floatingOffsetParent || isFixed && floatingOffsetParent !== getWindow(element)) { + return false; + } + return isFixed; + } + + function getBoundingClientRect(element, includeScale, isFixedStrategy, offsetParent) { + if (includeScale === void 0) { + includeScale = false; + } + if (isFixedStrategy === void 0) { + isFixedStrategy = false; + } + const clientRect = element.getBoundingClientRect(); + const domElement = unwrapElement(element); + let scale = createCoords(1); + if (includeScale) { + if (offsetParent) { + if (isElement(offsetParent)) { + scale = getScale(offsetParent); + } + } else { + scale = getScale(element); + } + } + const visualOffsets = shouldAddVisualOffsets(domElement, isFixedStrategy, offsetParent) ? getVisualOffsets(domElement) : createCoords(0); + let x = (clientRect.left + visualOffsets.x) / scale.x; + let y = (clientRect.top + visualOffsets.y) / scale.y; + let width = clientRect.width / scale.x; + let height = clientRect.height / scale.y; + if (domElement) { + const win = getWindow(domElement); + const offsetWin = offsetParent && isElement(offsetParent) ? getWindow(offsetParent) : offsetParent; + let currentWin = win; + let currentIFrame = getFrameElement(currentWin); + while (currentIFrame && offsetParent && offsetWin !== currentWin) { + const iframeScale = getScale(currentIFrame); + const iframeRect = currentIFrame.getBoundingClientRect(); + const css = getComputedStyle$1(currentIFrame); + const left = iframeRect.left + (currentIFrame.clientLeft + parseFloat(css.paddingLeft)) * iframeScale.x; + const top = iframeRect.top + (currentIFrame.clientTop + parseFloat(css.paddingTop)) * iframeScale.y; + x *= iframeScale.x; + y *= iframeScale.y; + width *= iframeScale.x; + height *= iframeScale.y; + x += left; + y += top; + currentWin = getWindow(currentIFrame); + currentIFrame = getFrameElement(currentWin); + } + } + return core.rectToClientRect({ + width, + height, + x, + y + }); + } + + // If has a CSS width greater than the viewport, then this will be + // incorrect for RTL. + function getWindowScrollBarX(element, rect) { + const leftScroll = getNodeScroll(element).scrollLeft; + if (!rect) { + return getBoundingClientRect(getDocumentElement(element)).left + leftScroll; + } + return rect.left + leftScroll; + } + + function getHTMLOffset(documentElement, scroll) { + const htmlRect = documentElement.getBoundingClientRect(); + const x = htmlRect.left + scroll.scrollLeft - getWindowScrollBarX(documentElement, htmlRect); + const y = htmlRect.top + scroll.scrollTop; + return { + x, + y + }; + } + + function convertOffsetParentRelativeRectToViewportRelativeRect(_ref) { + let { + elements, + rect, + offsetParent, + strategy + } = _ref; + const isFixed = strategy === 'fixed'; + const documentElement = getDocumentElement(offsetParent); + const topLayer = elements ? isTopLayer(elements.floating) : false; + if (offsetParent === documentElement || topLayer && isFixed) { + return rect; + } + let scroll = { + scrollLeft: 0, + scrollTop: 0 + }; + let scale = createCoords(1); + const offsets = createCoords(0); + const isOffsetParentAnElement = isHTMLElement(offsetParent); + if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) { + if (getNodeName(offsetParent) !== 'body' || isOverflowElement(documentElement)) { + scroll = getNodeScroll(offsetParent); + } + if (isOffsetParentAnElement) { + const offsetRect = getBoundingClientRect(offsetParent); + scale = getScale(offsetParent); + offsets.x = offsetRect.x + offsetParent.clientLeft; + offsets.y = offsetRect.y + offsetParent.clientTop; + } + } + const htmlOffset = documentElement && !isOffsetParentAnElement && !isFixed ? getHTMLOffset(documentElement, scroll) : createCoords(0); + return { + width: rect.width * scale.x, + height: rect.height * scale.y, + x: rect.x * scale.x - scroll.scrollLeft * scale.x + offsets.x + htmlOffset.x, + y: rect.y * scale.y - scroll.scrollTop * scale.y + offsets.y + htmlOffset.y + }; + } + + function getClientRects(element) { + return Array.from(element.getClientRects()); + } + + // Gets the entire size of the scrollable document area, even extending outside + // of the `` and `` rect bounds if horizontally scrollable. + function getDocumentRect(element) { + const html = getDocumentElement(element); + const scroll = getNodeScroll(element); + const body = element.ownerDocument.body; + const width = max(html.scrollWidth, html.clientWidth, body.scrollWidth, body.clientWidth); + const height = max(html.scrollHeight, html.clientHeight, body.scrollHeight, body.clientHeight); + let x = -scroll.scrollLeft + getWindowScrollBarX(element); + const y = -scroll.scrollTop; + if (getComputedStyle$1(body).direction === 'rtl') { + x += max(html.clientWidth, body.clientWidth) - width; + } + return { + width, + height, + x, + y + }; + } + + // Safety check: ensure the scrollbar space is reasonable in case this + // calculation is affected by unusual styles. + // Most scrollbars leave 15-18px of space. + const SCROLLBAR_MAX = 25; + function getViewportRect(element, strategy) { + const win = getWindow(element); + const html = getDocumentElement(element); + const visualViewport = win.visualViewport; + let width = html.clientWidth; + let height = html.clientHeight; + let x = 0; + let y = 0; + if (visualViewport) { + width = visualViewport.width; + height = visualViewport.height; + const visualViewportBased = isWebKit(); + if (!visualViewportBased || visualViewportBased && strategy === 'fixed') { + x = visualViewport.offsetLeft; + y = visualViewport.offsetTop; + } + } + const windowScrollbarX = getWindowScrollBarX(html); + // `overflow: hidden` + `scrollbar-gutter: stable` reduces the + // visual width of the but this is not considered in the size + // of `html.clientWidth`. + if (windowScrollbarX <= 0) { + const doc = html.ownerDocument; + const body = doc.body; + const bodyStyles = getComputedStyle(body); + const bodyMarginInline = doc.compatMode === 'CSS1Compat' ? parseFloat(bodyStyles.marginLeft) + parseFloat(bodyStyles.marginRight) || 0 : 0; + const clippingStableScrollbarWidth = Math.abs(html.clientWidth - body.clientWidth - bodyMarginInline); + if (clippingStableScrollbarWidth <= SCROLLBAR_MAX) { + width -= clippingStableScrollbarWidth; + } + } else if (windowScrollbarX <= SCROLLBAR_MAX) { + // If the scrollbar is on the left, the width needs to be extended + // by the scrollbar amount so there isn't extra space on the right. + width += windowScrollbarX; + } + return { + width, + height, + x, + y + }; + } + + // Returns the inner client rect, subtracting scrollbars if present. + function getInnerBoundingClientRect(element, strategy) { + const clientRect = getBoundingClientRect(element, true, strategy === 'fixed'); + const top = clientRect.top + element.clientTop; + const left = clientRect.left + element.clientLeft; + const scale = isHTMLElement(element) ? getScale(element) : createCoords(1); + const width = element.clientWidth * scale.x; + const height = element.clientHeight * scale.y; + const x = left * scale.x; + const y = top * scale.y; + return { + width, + height, + x, + y + }; + } + function getClientRectFromClippingAncestor(element, clippingAncestor, strategy) { + let rect; + if (clippingAncestor === 'viewport') { + rect = getViewportRect(element, strategy); + } else if (clippingAncestor === 'document') { + rect = getDocumentRect(getDocumentElement(element)); + } else if (isElement(clippingAncestor)) { + rect = getInnerBoundingClientRect(clippingAncestor, strategy); + } else { + const visualOffsets = getVisualOffsets(element); + rect = { + x: clippingAncestor.x - visualOffsets.x, + y: clippingAncestor.y - visualOffsets.y, + width: clippingAncestor.width, + height: clippingAncestor.height + }; + } + return core.rectToClientRect(rect); + } + function hasFixedPositionAncestor(element, stopNode) { + const parentNode = getParentNode(element); + if (parentNode === stopNode || !isElement(parentNode) || isLastTraversableNode(parentNode)) { + return false; + } + return getComputedStyle$1(parentNode).position === 'fixed' || hasFixedPositionAncestor(parentNode, stopNode); + } + + // A "clipping ancestor" is an `overflow` element with the characteristic of + // clipping (or hiding) child elements. This returns all clipping ancestors + // of the given element up the tree. + function getClippingElementAncestors(element, cache) { + const cachedResult = cache.get(element); + if (cachedResult) { + return cachedResult; + } + let result = getOverflowAncestors(element, [], false).filter(el => isElement(el) && getNodeName(el) !== 'body'); + let currentContainingBlockComputedStyle = null; + const elementIsFixed = getComputedStyle$1(element).position === 'fixed'; + let currentNode = elementIsFixed ? getParentNode(element) : element; + + // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block + while (isElement(currentNode) && !isLastTraversableNode(currentNode)) { + const computedStyle = getComputedStyle$1(currentNode); + const currentNodeIsContaining = isContainingBlock(currentNode); + if (!currentNodeIsContaining && computedStyle.position === 'fixed') { + currentContainingBlockComputedStyle = null; + } + const shouldDropCurrentNode = elementIsFixed ? !currentNodeIsContaining && !currentContainingBlockComputedStyle : !currentNodeIsContaining && computedStyle.position === 'static' && !!currentContainingBlockComputedStyle && (currentContainingBlockComputedStyle.position === 'absolute' || currentContainingBlockComputedStyle.position === 'fixed') || isOverflowElement(currentNode) && !currentNodeIsContaining && hasFixedPositionAncestor(element, currentNode); + if (shouldDropCurrentNode) { + // Drop non-containing blocks. + result = result.filter(ancestor => ancestor !== currentNode); + } else { + // Record last containing block for next iteration. + currentContainingBlockComputedStyle = computedStyle; + } + currentNode = getParentNode(currentNode); + } + cache.set(element, result); + return result; + } + + // Gets the maximum area that the element is visible in due to any number of + // clipping ancestors. + function getClippingRect(_ref) { + let { + element, + boundary, + rootBoundary, + strategy + } = _ref; + const elementClippingAncestors = boundary === 'clippingAncestors' ? isTopLayer(element) ? [] : getClippingElementAncestors(element, this._c) : [].concat(boundary); + const clippingAncestors = [...elementClippingAncestors, rootBoundary]; + const firstRect = getClientRectFromClippingAncestor(element, clippingAncestors[0], strategy); + let top = firstRect.top; + let right = firstRect.right; + let bottom = firstRect.bottom; + let left = firstRect.left; + for (let i = 1; i < clippingAncestors.length; i++) { + const rect = getClientRectFromClippingAncestor(element, clippingAncestors[i], strategy); + top = max(rect.top, top); + right = min(rect.right, right); + bottom = min(rect.bottom, bottom); + left = max(rect.left, left); + } + return { + width: right - left, + height: bottom - top, + x: left, + y: top + }; + } + + function getDimensions(element) { + const { + width, + height + } = getCssDimensions(element); + return { + width, + height + }; + } + + function getRectRelativeToOffsetParent(element, offsetParent, strategy) { + const isOffsetParentAnElement = isHTMLElement(offsetParent); + const documentElement = getDocumentElement(offsetParent); + const isFixed = strategy === 'fixed'; + const rect = getBoundingClientRect(element, true, isFixed, offsetParent); + let scroll = { + scrollLeft: 0, + scrollTop: 0 + }; + const offsets = createCoords(0); + + // If the scrollbar appears on the left (e.g. RTL systems). Use + // Firefox with layout.scrollbar.side = 3 in about:config to test this. + function setLeftRTLScrollbarOffset() { + offsets.x = getWindowScrollBarX(documentElement); + } + if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) { + if (getNodeName(offsetParent) !== 'body' || isOverflowElement(documentElement)) { + scroll = getNodeScroll(offsetParent); + } + if (isOffsetParentAnElement) { + const offsetRect = getBoundingClientRect(offsetParent, true, isFixed, offsetParent); + offsets.x = offsetRect.x + offsetParent.clientLeft; + offsets.y = offsetRect.y + offsetParent.clientTop; + } else if (documentElement) { + setLeftRTLScrollbarOffset(); + } + } + if (isFixed && !isOffsetParentAnElement && documentElement) { + setLeftRTLScrollbarOffset(); + } + const htmlOffset = documentElement && !isOffsetParentAnElement && !isFixed ? getHTMLOffset(documentElement, scroll) : createCoords(0); + const x = rect.left + scroll.scrollLeft - offsets.x - htmlOffset.x; + const y = rect.top + scroll.scrollTop - offsets.y - htmlOffset.y; + return { + x, + y, + width: rect.width, + height: rect.height + }; + } + + function isStaticPositioned(element) { + return getComputedStyle$1(element).position === 'static'; + } + + function getTrueOffsetParent(element, polyfill) { + if (!isHTMLElement(element) || getComputedStyle$1(element).position === 'fixed') { + return null; + } + if (polyfill) { + return polyfill(element); + } + let rawOffsetParent = element.offsetParent; + + // Firefox returns the element as the offsetParent if it's non-static, + // while Chrome and Safari return the element. The element must + // be used to perform the correct calculations even if the element is + // non-static. + if (getDocumentElement(element) === rawOffsetParent) { + rawOffsetParent = rawOffsetParent.ownerDocument.body; + } + return rawOffsetParent; + } + + // Gets the closest ancestor positioned element. Handles some edge cases, + // such as table ancestors and cross browser bugs. + function getOffsetParent(element, polyfill) { + const win = getWindow(element); + if (isTopLayer(element)) { + return win; + } + if (!isHTMLElement(element)) { + let svgOffsetParent = getParentNode(element); + while (svgOffsetParent && !isLastTraversableNode(svgOffsetParent)) { + if (isElement(svgOffsetParent) && !isStaticPositioned(svgOffsetParent)) { + return svgOffsetParent; + } + svgOffsetParent = getParentNode(svgOffsetParent); + } + return win; + } + let offsetParent = getTrueOffsetParent(element, polyfill); + while (offsetParent && isTableElement(offsetParent) && isStaticPositioned(offsetParent)) { + offsetParent = getTrueOffsetParent(offsetParent, polyfill); + } + if (offsetParent && isLastTraversableNode(offsetParent) && isStaticPositioned(offsetParent) && !isContainingBlock(offsetParent)) { + return win; + } + return offsetParent || getContainingBlock(element) || win; + } + + const getElementRects = async function (data) { + const getOffsetParentFn = this.getOffsetParent || getOffsetParent; + const getDimensionsFn = this.getDimensions; + const floatingDimensions = await getDimensionsFn(data.floating); + return { + reference: getRectRelativeToOffsetParent(data.reference, await getOffsetParentFn(data.floating), data.strategy), + floating: { + x: 0, + y: 0, + width: floatingDimensions.width, + height: floatingDimensions.height + } + }; + }; + + function isRTL(element) { + return getComputedStyle$1(element).direction === 'rtl'; + } + + const platform = { + convertOffsetParentRelativeRectToViewportRelativeRect, + getDocumentElement, + getClippingRect, + getOffsetParent, + getElementRects, + getClientRects, + getDimensions, + getScale, + isElement, + isRTL + }; + + function rectsAreEqual(a, b) { + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + } + + // https://samthor.au/2021/observing-dom/ + function observeMove(element, onMove) { + let io = null; + let timeoutId; + const root = getDocumentElement(element); + function cleanup() { + var _io; + clearTimeout(timeoutId); + (_io = io) == null || _io.disconnect(); + io = null; + } + function refresh(skip, threshold) { + if (skip === void 0) { + skip = false; + } + if (threshold === void 0) { + threshold = 1; + } + cleanup(); + const elementRectForRootMargin = element.getBoundingClientRect(); + const { + left, + top, + width, + height + } = elementRectForRootMargin; + if (!skip) { + onMove(); + } + if (!width || !height) { + return; + } + const insetTop = floor(top); + const insetRight = floor(root.clientWidth - (left + width)); + const insetBottom = floor(root.clientHeight - (top + height)); + const insetLeft = floor(left); + const rootMargin = -insetTop + "px " + -insetRight + "px " + -insetBottom + "px " + -insetLeft + "px"; + const options = { + rootMargin, + threshold: max(0, min(1, threshold)) || 1 + }; + let isFirstUpdate = true; + function handleObserve(entries) { + const ratio = entries[0].intersectionRatio; + if (ratio !== threshold) { + if (!isFirstUpdate) { + return refresh(); + } + if (!ratio) { + // If the reference is clipped, the ratio is 0. Throttle the refresh + // to prevent an infinite loop of updates. + timeoutId = setTimeout(() => { + refresh(false, 1e-7); + }, 1000); + } else { + refresh(false, ratio); + } + } + if (ratio === 1 && !rectsAreEqual(elementRectForRootMargin, element.getBoundingClientRect())) { + // It's possible that even though the ratio is reported as 1, the + // element is not actually fully within the IntersectionObserver's root + // area anymore. This can happen under performance constraints. This may + // be a bug in the browser's IntersectionObserver implementation. To + // work around this, we compare the element's bounding rect now with + // what it was at the time we created the IntersectionObserver. If they + // are not equal then the element moved, so we refresh. + refresh(); + } + isFirstUpdate = false; + } + + // Older browsers don't support a `document` as the root and will throw an + // error. + try { + io = new IntersectionObserver(handleObserve, { + ...options, + // Handle