summaryrefslogtreecommitdiff
path: root/source
diff options
context:
space:
mode:
authorMichael Peter Christen <mc@yacy.net>2026-05-16 14:21:50 +0200
committerMichael Peter Christen <mc@yacy.net>2026-05-16 14:21:50 +0200
commit77f556a77d7cfca4a503476f94bffe0fa1ef6d01 (patch)
tree2db60f46034bc052e0187c7450e406393d4f3676 /source
parentf9b0ced1af2e2c4af2904b43a0704b4be8101dbf (diff)
added experimental artifact envelope tool and plotter
Diffstat (limited to 'source')
-rw-r--r--source/net/yacy/ai/tools/ArtifactEnvelopeTool.java699
-rw-r--r--source/net/yacy/visualization/ArtifactPlotter.java584
2 files changed, 1283 insertions, 0 deletions
diff --git a/source/net/yacy/ai/tools/ArtifactEnvelopeTool.java b/source/net/yacy/ai/tools/ArtifactEnvelopeTool.java
new file mode 100644
index 000000000..9842021fb
--- /dev/null
+++ b/source/net/yacy/ai/tools/ArtifactEnvelopeTool.java
@@ -0,0 +1,699 @@
+/**
+ * ArtifactEnvelopeTool
+ * Copyright 2026 by Michael Peter Christen
+ * First released 11.02.2026 at https://yacy.net
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program in the file lgpl21.txt
+ * If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.yacy.ai.tools;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+
+import net.yacy.ai.ToolHandler;
+
+/**
+ * Build a renderer-ready artifact envelope from structured context data.
+ *
+ * <h2>Introduction: idea and principle</h2>
+ * This tool bridges two worlds:
+ * <ul>
+ * <li><b>LLM/tool output</b>: plain structured JSON data extracted from conversation context.</li>
+ * <li><b>Frontend visualization</b>: a dedicated renderer that expects a normalized artifact envelope.</li>
+ * </ul>
+ *
+ * The central idea is to make visualization generation deterministic and transparent.
+ * Instead of asking the model to invent arbitrary UI markup, this tool converts known
+ * data into a stable envelope schema:
+ * <pre>
+ * {
+ * "kind": "artifact",
+ * "artifact_type": "chart|graph",
+ * "renderer": "...",
+ * "spec": { ... renderer-specific payload ... }
+ * }
+ * </pre>
+ *
+ * <h3>Design principles</h3>
+ * <ul>
+ * <li><b>Deterministic mapping</b>: same input structure yields same artifact shape.</li>
+ * <li><b>Schema-first output</b>: frontend can render without reinterpreting natural language.</li>
+ * <li><b>Heuristic, but explainable</b>: lightweight field inference for x/y/series or graph edges.</li>
+ * <li><b>Fail fast</b>: invalid or insufficient data returns explicit tool errors.</li>
+ * </ul>
+ *
+ * <h3>Transformation strategy</h3>
+ * <ol>
+ * <li>Normalize incoming {@code data} into a row array (JSON objects).</li>
+ * <li>Choose artifact mode:
+ * <ul>
+ * <li>Graph when input looks like an edge list or caller hints {@code graph}.</li>
+ * <li>Chart otherwise (default path).</li>
+ * </ul>
+ * </li>
+ * <li>Infer missing fields (x/y/series or source/target) from row content.</li>
+ * <li>Emit envelope with renderer-specific {@code spec}:
+ * <ul>
+ * <li>Vega-Lite for charts</li>
+ * <li>Cytoscape-style node/edge spec for graphs</li>
+ * </ul>
+ * </li>
+ * </ol>
+ */
+public class ArtifactEnvelopeTool implements ToolHandler {
+
+ private static final String NAME = "artifact_envelope";
+ private static final String VEGA_SCHEMA = "https://vega.github.io/schema/vega-lite/v5.json";
+
+ @Override
+ public JSONObject definition() throws JSONException {
+ final JSONObject tool = new JSONObject(true);
+ tool.put("type", "function");
+ final JSONObject fn = new JSONObject(true);
+ fn.put("name", NAME);
+ fn.put("description", "Transform structured context data into a graphical artifact envelope for chart or graph rendering.");
+
+ final JSONObject params = new JSONObject(true);
+ params.put("type", "object");
+ final JSONObject props = new JSONObject(true);
+
+ props.put("data", new JSONObject(true)
+ .put("description", "Structured input data. Prefer array of row objects; object or JSON string also accepted."));
+ props.put("artifact_hint", new JSONObject(true)
+ .put("type", "string")
+ .put("description", "Optional hint: auto, chart, or graph."));
+ props.put("title", new JSONObject(true)
+ .put("type", "string")
+ .put("description", "Optional chart or graph title."));
+ props.put("renderer", new JSONObject(true)
+ .put("type", "string")
+ .put("description", "Optional renderer override. Defaults: vega-lite for charts, cytoscape for graphs."));
+ props.put("x_field", new JSONObject(true)
+ .put("type", "string")
+ .put("description", "Optional x axis field for chart artifacts."));
+ props.put("y_field", new JSONObject(true)
+ .put("type", "string")
+ .put("description", "Optional y axis field for chart artifacts."));
+ props.put("series_field", new JSONObject(true)
+ .put("type", "string")
+ .put("description", "Optional series/group field for chart color encoding."));
+ props.put("source_field", new JSONObject(true)
+ .put("type", "string")
+ .put("description", "Optional source field for graph edge lists."));
+ props.put("target_field", new JSONObject(true)
+ .put("type", "string")
+ .put("description", "Optional target field for graph edge lists."));
+ props.put("chart_mark", new JSONObject(true)
+ .put("type", "string")
+ .put("description", "Optional Vega-Lite mark override: line, bar, point, area."));
+
+ params.put("properties", props);
+ params.put("required", new JSONArray().put("data"));
+ fn.put("parameters", params);
+ tool.put("function", fn);
+ return tool;
+ }
+
+ @Override
+ public int maxCallsPerTurn() {
+ return 3;
+ }
+
+ @Override
+ public String execute(final String arguments) {
+ // Parse tool-call arguments strictly once; all downstream logic works on this object.
+ final JSONObject args;
+ try {
+ args = (arguments == null || arguments.isEmpty()) ? new JSONObject(true) : new JSONObject(arguments);
+ } catch (final JSONException e) {
+ return ToolHandler.errorJson("Invalid arguments JSON");
+ }
+
+ // "data" is the only hard requirement: we cannot produce a visualization envelope without it.
+ final Object dataRaw = args.opt("data");
+ if (dataRaw == null || dataRaw == JSONObject.NULL) return ToolHandler.errorJson("Missing data");
+
+ // Normalize arbitrary accepted inputs (array/object/stringified JSON) into row objects.
+ final JSONArray rows;
+ try {
+ rows = normalizeRows(dataRaw);
+ } catch (final JSONException e) {
+ return ToolHandler.errorJson("Failed to parse data: " + e.getMessage());
+ }
+ if (rows.length() == 0) return ToolHandler.errorJson("No rows available after normalization");
+
+ // Optional hints allow caller control while preserving safe defaults.
+ final String artifactHint = normalizeHint(args.optString("artifact_hint", "auto"));
+ final String title = trimToNull(args.optString("title", null));
+ final String rendererOverride = trimToNull(args.optString("renderer", null));
+
+ try {
+ // Artifact type choice:
+ // 1) explicit hint wins
+ // 2) otherwise detect graph-like edge-list structure
+ // 3) fallback is chart
+ if ("graph".equals(artifactHint) || isGraphLike(rows, args)) {
+ return buildGraphArtifact(rows, args, title, rendererOverride).toString();
+ }
+ return buildChartArtifact(rows, args, title, rendererOverride).toString();
+ } catch (final JSONException | IllegalArgumentException e) {
+ return ToolHandler.errorJson(e.getMessage() == null ? "Failed to build artifact envelope" : e.getMessage());
+ }
+ }
+
+ private static JSONObject buildChartArtifact(final JSONArray rows, final JSONObject args,
+ final String title, final String rendererOverride) throws JSONException {
+ // Clone rows so local transforms (such as adding synthetic index or grouping fallback)
+ // do not mutate caller-provided JSON structures.
+ JSONArray chartRows = cloneRows(rows);
+
+ // Scan fields once to classify likely numeric/temporal/string roles.
+ final FieldProfile profile = inferFieldProfile(chartRows);
+
+ // User-configured fields are respected only when they actually exist in data.
+ String xField = firstExistingField(args, chartRows, "x_field");
+ String yField = firstExistingField(args, chartRows, "y_field");
+ String seriesField = firstExistingField(args, chartRows, "series_field");
+
+ if (xField == null || yField == null) {
+ // Fill missing axes via deterministic heuristic (temporal->x, numeric->y, etc.).
+ final FieldChoice inferred = chooseChartFields(chartRows, profile, xField, yField);
+ if (xField == null) xField = inferred.xField;
+ if (yField == null) yField = inferred.yField;
+ }
+
+ if (xField == null || yField == null) {
+ // Last-resort fallback:
+ // If we cannot derive meaningful x/y directly, aggregate category counts.
+ // This still produces a useful bar-chart-ready artifact for categorical data.
+ final String fallbackField = profile.firstStringField();
+ if (fallbackField == null) throw new IllegalArgumentException("Could not infer chart axes from data");
+ final JSONArray grouped = countByField(chartRows, fallbackField);
+ chartRows = grouped;
+ xField = "category";
+ yField = "count";
+ seriesField = null;
+ }
+
+ if (seriesField == null) {
+ // Optional color grouping: only selected when a suitable low-cardinality string field exists.
+ seriesField = inferSeriesField(chartRows, xField, yField, profile);
+ }
+
+ // Map internal field classification to Vega-Lite type system.
+ final String xType = vegaTypeForField(chartRows, xField, profile);
+ final String yType = vegaTypeForField(chartRows, yField, profile);
+
+ // Select chart mark automatically unless caller overrides with a supported mark.
+ final String mark = chooseMark(args.optString("chart_mark", ""), xType, yType);
+
+ // Build Vega-Lite encoding block.
+ final JSONObject encoding = new JSONObject(true);
+ encoding.put("x", axis(xField, xType));
+ encoding.put("y", axis(yField, yType));
+ if (seriesField != null && !seriesField.equals(xField) && !seriesField.equals(yField)) {
+ encoding.put("color", axis(seriesField, "nominal"));
+ }
+
+ // Build renderer payload ("spec") and wrap it in the common artifact envelope.
+ final JSONObject spec = new JSONObject(true);
+ spec.put("$schema", VEGA_SCHEMA);
+ if (title != null) spec.put("title", title);
+ spec.put("mark", mark);
+ spec.put("encoding", encoding);
+ spec.put("data", new JSONObject(true).put("values", chartRows));
+
+ final JSONObject out = new JSONObject(true);
+ out.put("kind", "artifact");
+ out.put("artifact_type", "chart");
+ out.put("renderer", rendererOverride == null ? "vega-lite" : rendererOverride);
+ out.put("spec", spec);
+ return out;
+ }
+
+ private static JSONObject buildGraphArtifact(final JSONArray rows, final JSONObject args,
+ final String title, final String rendererOverride) throws JSONException {
+ // Identify source/target edge fields from explicit arguments first, then common conventions.
+ final String sourceField = chooseEdgeField(args.optString("source_field", ""), rows,
+ new String[] { "source", "from", "src", "origin" });
+ final String targetField = chooseEdgeField(args.optString("target_field", ""), rows,
+ new String[] { "target", "to", "dst", "destination" });
+ if (sourceField == null || targetField == null) {
+ throw new IllegalArgumentException("Graph artifact requires source/target fields");
+ }
+
+ // Convert edge-list rows to a deduplicated node set + edge array.
+ // Output format is intentionally simple for frontend adapters.
+ final Set<String> seenNodes = new LinkedHashSet<>();
+ final JSONArray nodes = new JSONArray();
+ final JSONArray edges = new JSONArray();
+ for (int i = 0; i < rows.length(); i++) {
+ final JSONObject row = rows.optJSONObject(i);
+ if (row == null) continue;
+ final String src = asNodeId(row.opt(sourceField));
+ final String dst = asNodeId(row.opt(targetField));
+ if (src == null || dst == null) continue;
+ if (seenNodes.add(src)) nodes.put(node(src));
+ if (seenNodes.add(dst)) nodes.put(node(dst));
+ final JSONObject edgeData = new JSONObject(true);
+ edgeData.put("id", "e" + i);
+ edgeData.put("source", src);
+ edgeData.put("target", dst);
+ if (row.has("weight")) edgeData.put("weight", row.opt("weight"));
+ edges.put(new JSONObject(true).put("data", edgeData));
+ }
+ if (edges.length() == 0) throw new IllegalArgumentException("No valid edges found in data");
+
+ final JSONObject spec = new JSONObject(true);
+ if (title != null) spec.put("title", title);
+ spec.put("nodes", nodes);
+ spec.put("edges", edges);
+
+ // Graph envelope payload; default renderer targets common graph frontend adapters.
+ final JSONObject out = new JSONObject(true);
+ out.put("kind", "artifact");
+ out.put("artifact_type", "graph");
+ out.put("renderer", rendererOverride == null ? "cytoscape" : rendererOverride);
+ out.put("spec", spec);
+ return out;
+ }
+
+ private static JSONObject node(final String id) throws JSONException {
+ return new JSONObject(true).put("data", new JSONObject(true).put("id", id).put("label", id));
+ }
+
+ private static JSONObject axis(final String field, final String type) throws JSONException {
+ return new JSONObject(true).put("field", field).put("type", type);
+ }
+
+ private static String chooseMark(final String override, final String xType, final String yType) {
+ // Respect explicit override when it is one of the marks we support safely.
+ final String normalized = trimToNull(override);
+ if (normalized != null) {
+ final String lower = normalized.toLowerCase(Locale.ROOT);
+ if ("line".equals(lower) || "bar".equals(lower) || "point".equals(lower) || "area".equals(lower)) return lower;
+ }
+ // Deterministic default heuristics by axis type.
+ if ("temporal".equals(xType) && "quantitative".equals(yType)) return "line";
+ if ("nominal".equals(xType) && "quantitative".equals(yType)) return "bar";
+ if ("quantitative".equals(xType) && "quantitative".equals(yType)) return "point";
+ return "bar";
+ }
+
+ private static String normalizeHint(final String hint) {
+ final String value = trimToNull(hint);
+ if (value == null) return "auto";
+ final String lower = value.toLowerCase(Locale.ROOT);
+ if ("chart".equals(lower) || "graph".equals(lower) || "auto".equals(lower)) return lower;
+ return "auto";
+ }
+
+ private static JSONArray normalizeRows(final Object dataRaw) throws JSONException {
+ // The tool accepts multiple shapes to be easy to call from other tools/LLM outputs:
+ // - array of objects
+ // - object containing values/rows arrays
+ // - plain object (single row)
+ // - JSON string representing any of the above
+ if (dataRaw instanceof JSONArray) return toRowObjects((JSONArray) dataRaw);
+ if (dataRaw instanceof JSONObject) return fromObject((JSONObject) dataRaw);
+ if (dataRaw instanceof String) {
+ final String text = ((String) dataRaw).trim();
+ if (text.isEmpty()) return new JSONArray();
+ final Object parsed = new JSONTokener(text).nextValue();
+ if (parsed instanceof JSONArray) return toRowObjects((JSONArray) parsed);
+ if (parsed instanceof JSONObject) return fromObject((JSONObject) parsed);
+ throw new JSONException("String data is not a JSON object or array");
+ }
+ return new JSONArray().put(new JSONObject(true).put("value", dataRaw));
+ }
+
+ private static JSONArray fromObject(final JSONObject obj) throws JSONException {
+ if (obj == null) return new JSONArray();
+ final JSONArray values = obj.optJSONArray("values");
+ if (values != null) return toRowObjects(values);
+ final JSONArray rows = obj.optJSONArray("rows");
+ if (rows != null) return toRowObjects(rows);
+ return new JSONArray().put(obj);
+ }
+
+ private static JSONArray toRowObjects(final JSONArray array) throws JSONException {
+ final JSONArray rows = new JSONArray();
+ for (int i = 0; i < array.length(); i++) {
+ final Object v = array.opt(i);
+ if (v instanceof JSONObject) {
+ rows.put((JSONObject) v);
+ } else {
+ // Scalar array elements are wrapped to preserve position and value.
+ rows.put(new JSONObject(true).put("index", i).put("value", v));
+ }
+ }
+ return rows;
+ }
+
+ private static JSONArray cloneRows(final JSONArray in) throws JSONException {
+ final JSONArray out = new JSONArray();
+ for (int i = 0; i < in.length(); i++) {
+ final JSONObject row = in.optJSONObject(i);
+ if (row == null) continue;
+ out.put(new JSONObject(row.toString()));
+ }
+ return out;
+ }
+
+ private static boolean isGraphLike(final JSONArray rows, final JSONObject args) {
+ // Explicitly configured source+target implies graph intent.
+ final String configuredSource = trimToNull(args.optString("source_field", null));
+ final String configuredTarget = trimToNull(args.optString("target_field", null));
+ if (configuredSource != null && configuredTarget != null) return true;
+
+ // Otherwise, treat data as graph when most rows look like edges.
+ int candidates = 0;
+ for (int i = 0; i < rows.length(); i++) {
+ final JSONObject row = rows.optJSONObject(i);
+ if (row == null) continue;
+ final String source = chooseEdgeField(configuredSource, row, new String[] { "source", "from", "src", "origin" });
+ final String target = chooseEdgeField(configuredTarget, row, new String[] { "target", "to", "dst", "destination" });
+ if (source != null && target != null) candidates++;
+ }
+ return candidates > 0 && candidates >= Math.max(1, rows.length() / 2);
+ }
+
+ private static String chooseEdgeField(final String preferred, final JSONArray rows, final String[] fallbacks) {
+ if (preferred != null && preferred.length() > 0 && hasField(rows, preferred)) return preferred;
+ for (final String fallback : fallbacks) {
+ if (hasField(rows, fallback)) return fallback;
+ }
+ return null;
+ }
+
+ private static String chooseEdgeField(final String preferred, final JSONObject row, final String[] fallbacks) {
+ if (row == null) return null;
+ if (preferred != null && preferred.length() > 0 && row.has(preferred)) return preferred;
+ for (final String fallback : fallbacks) {
+ if (row.has(fallback)) return fallback;
+ }
+ return null;
+ }
+
+ private static boolean hasField(final JSONArray rows, final String field) {
+ if (field == null || field.isEmpty()) return false;
+ for (int i = 0; i < rows.length(); i++) {
+ final JSONObject row = rows.optJSONObject(i);
+ if (row != null && row.has(field)) return true;
+ }
+ return false;
+ }
+
+ private static String firstExistingField(final JSONObject args, final JSONArray rows, final String argumentName) {
+ final String field = trimToNull(args.optString(argumentName, null));
+ if (field == null) return null;
+ return hasField(rows, field) ? field : null;
+ }
+
+ private static String trimToNull(final String value) {
+ if (value == null) return null;
+ final String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
+ private static String asNodeId(final Object value) {
+ if (value == null || value == JSONObject.NULL) return null;
+ final String text = String.valueOf(value).trim();
+ return text.isEmpty() ? null : text;
+ }
+
+ private static FieldChoice chooseChartFields(final JSONArray rows, final FieldProfile profile,
+ final String configuredX, final String configuredY) throws JSONException {
+ String x = configuredX;
+ String y = configuredY;
+
+ // Preferred order:
+ // x: temporal -> string -> numeric -> synthetic index
+ // y: numeric
+ if (x == null && !profile.temporalFields.isEmpty()) x = profile.temporalFields.get(0);
+ if (y == null) {
+ final String numeric = profile.firstNumericFieldExcluding(x);
+ if (numeric != null) y = numeric;
+ }
+
+ if (x == null && y != null) {
+ final String candidate = profile.firstStringFieldExcluding(y);
+ if (candidate != null) x = candidate;
+ }
+ if (x == null) {
+ final String candidate = profile.firstNumericFieldExcluding(y);
+ if (candidate != null) x = candidate;
+ }
+
+ if (x == null && y != null) {
+ // When only y can be found, add row index as synthetic x axis.
+ addSequentialIndex(rows);
+ x = "index";
+ }
+ if (y == null && x != null) {
+ final String candidate = profile.firstNumericFieldExcluding(x);
+ if (candidate != null) y = candidate;
+ }
+
+ return new FieldChoice(x, y);
+ }
+
+ private static void addSequentialIndex(final JSONArray rows) throws JSONException {
+ for (int i = 0; i < rows.length(); i++) {
+ final JSONObject row = rows.optJSONObject(i);
+ if (row != null && !row.has("index")) row.put("index", i);
+ }
+ }
+
+ private static String inferSeriesField(final JSONArray rows, final String xField,
+ final String yField, final FieldProfile profile) {
+ final List<String> strings = profile.stringFields;
+ for (int i = 0; i < strings.size(); i++) {
+ final String candidate = strings.get(i);
+ if (candidate.equals(xField) || candidate.equals(yField)) continue;
+ // Keep chart readable by selecting only low-cardinality categories as series.
+ final int distinct = distinctValueCount(rows, candidate, 20);
+ if (distinct >= 2 && distinct <= 8) return candidate;
+ }
+ return null;
+ }
+
+ private static String vegaTypeForField(final JSONArray rows, final String field, final FieldProfile profile) {
+ if (field == null) return "nominal";
+ if ("index".equals(field)) return "quantitative";
+ if (profile.temporalFields.contains(field)) return "temporal";
+ if (profile.numericFields.contains(field)) return "quantitative";
+ // Recheck values for late-added synthetic fields or uncertain classifications.
+ final int temporalVotes = countTemporalValues(rows, field);
+ final int numericVotes = countNumericValues(rows, field);
+ if (temporalVotes > 0 && temporalVotes >= numericVotes) return "temporal";
+ if (numericVotes > 0) return "quantitative";
+ return "nominal";
+ }
+
+ private static JSONArray countByField(final JSONArray rows, final String field) throws JSONException {
+ final Map<String, Integer> counts = new LinkedHashMap<>();
+ for (int i = 0; i < rows.length(); i++) {
+ final JSONObject row = rows.optJSONObject(i);
+ if (row == null) continue;
+ final Object value = row.opt(field);
+ final String key = value == null || value == JSONObject.NULL ? "null" : String.valueOf(value);
+ final Integer current = counts.get(key);
+ counts.put(key, current == null ? 1 : current.intValue() + 1);
+ }
+ final JSONArray out = new JSONArray();
+ for (final Map.Entry<String, Integer> entry : counts.entrySet()) {
+ out.put(new JSONObject(true).put("category", entry.getKey()).put("count", entry.getValue()));
+ }
+ return out;
+ }
+
+ private static int distinctValueCount(final JSONArray rows, final String field, final int maxDistinct) {
+ final Set<String> distinct = new LinkedHashSet<>();
+ for (int i = 0; i < rows.length(); i++) {
+ final JSONObject row = rows.optJSONObject(i);
+ if (row == null || !row.has(field)) continue;
+ final Object v = row.opt(field);
+ if (v == null || v == JSONObject.NULL) continue;
+ distinct.add(String.valueOf(v));
+ if (distinct.size() > maxDistinct) return distinct.size();
+ }
+ return distinct.size();
+ }
+
+ private static int countTemporalValues(final JSONArray rows, final String field) {
+ int count = 0;
+ for (int i = 0; i < rows.length(); i++) {
+ final JSONObject row = rows.optJSONObject(i);
+ if (row == null) continue;
+ final Object value = row.opt(field);
+ if (looksTemporal(value)) count++;
+ }
+ return count;
+ }
+
+ private static int countNumericValues(final JSONArray rows, final String field) {
+ int count = 0;
+ for (int i = 0; i < rows.length(); i++) {
+ final JSONObject row = rows.optJSONObject(i);
+ if (row == null) continue;
+ final Object value = row.opt(field);
+ if (asDouble(value) != null) count++;
+ }
+ return count;
+ }
+
+ private static boolean looksTemporal(final Object value) {
+ if (value == null || value == JSONObject.NULL) return false;
+ if (value instanceof Number) {
+ // Numeric epoch-like values are treated as temporal candidates.
+ final double d = ((Number) value).doubleValue();
+ return d > 1_000_000_000d && d < 9_999_999_999_999d;
+ }
+ final String s = String.valueOf(value).trim();
+ if (s.isEmpty()) return false;
+ try {
+ Instant.parse(s);
+ return true;
+ } catch (final DateTimeParseException e) {
+ // next parser
+ }
+ try {
+ LocalDate.parse(s);
+ return true;
+ } catch (final DateTimeParseException e) {
+ return false;
+ }
+ }
+
+ private static Double asDouble(final Object value) {
+ if (value == null || value == JSONObject.NULL) return null;
+ if (value instanceof Number) return Double.valueOf(((Number) value).doubleValue());
+ final String text = String.valueOf(value).trim();
+ if (text.isEmpty()) return null;
+ try {
+ return Double.valueOf(Double.parseDouble(text));
+ } catch (final NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private static FieldProfile inferFieldProfile(final JSONArray rows) {
+ final Map<String, Integer> numericVotes = new LinkedHashMap<>();
+ final Map<String, Integer> temporalVotes = new LinkedHashMap<>();
+ final Map<String, Integer> stringVotes = new LinkedHashMap<>();
+ final Map<String, Integer> presentVotes = new LinkedHashMap<>();
+
+ for (int i = 0; i < rows.length(); i++) {
+ final JSONObject row = rows.optJSONObject(i);
+ if (row == null) continue;
+ for (final String key : row.keySet()) {
+ increment(presentVotes, key);
+ final Object value = row.opt(key);
+ if (value == null || value == JSONObject.NULL) continue;
+ if (looksTemporal(value)) {
+ increment(temporalVotes, key);
+ continue;
+ }
+ if (asDouble(value) != null) {
+ increment(numericVotes, key);
+ } else {
+ increment(stringVotes, key);
+ }
+ }
+ }
+
+ final List<String> numeric = new ArrayList<>();
+ final List<String> temporal = new ArrayList<>();
+ final List<String> stringy = new ArrayList<>();
+
+ for (final Map.Entry<String, Integer> present : presentVotes.entrySet()) {
+ final String field = present.getKey();
+ final int total = Math.max(1, present.getValue().intValue());
+ final int temporalCount = temporalVotes.containsKey(field) ? temporalVotes.get(field).intValue() : 0;
+ final int numericCount = numericVotes.containsKey(field) ? numericVotes.get(field).intValue() : 0;
+ final int stringCount = stringVotes.containsKey(field) ? stringVotes.get(field).intValue() : 0;
+ if (temporalCount >= Math.max(1, (int) Math.ceil(total * 0.5d))) {
+ temporal.add(field);
+ } else if (numericCount >= Math.max(1, (int) Math.ceil(total * 0.5d))) {
+ numeric.add(field);
+ } else if (stringCount > 0) {
+ stringy.add(field);
+ }
+ }
+ return new FieldProfile(numeric, temporal, stringy);
+ }
+
+ private static void increment(final Map<String, Integer> map, final String key) {
+ final Integer value = map.get(key);
+ map.put(key, value == null ? Integer.valueOf(1) : Integer.valueOf(value.intValue() + 1));
+ }
+
+ private static final class FieldChoice {
+ final String xField;
+ final String yField;
+
+ FieldChoice(final String xField, final String yField) {
+ this.xField = xField;
+ this.yField = yField;
+ }
+ }
+
+ private static final class FieldProfile {
+ final List<String> numericFields;
+ final List<String> temporalFields;
+ final List<String> stringFields;
+
+ FieldProfile(final List<String> numericFields, final List<String> temporalFields, final List<String> stringFields) {
+ this.numericFields = numericFields == null ? new ArrayList<String>() : numericFields;
+ this.temporalFields = temporalFields == null ? new ArrayList<String>() : temporalFields;
+ this.stringFields = stringFields == null ? new ArrayList<String>() : stringFields;
+ }
+
+ String firstNumericFieldExcluding(final String exclude) {
+ for (int i = 0; i < this.numericFields.size(); i++) {
+ final String field = this.numericFields.get(i);
+ if (exclude == null || !exclude.equals(field)) return field;
+ }
+ return null;
+ }
+
+ String firstStringField() {
+ return this.stringFields.isEmpty() ? null : this.stringFields.get(0);
+ }
+
+ String firstStringFieldExcluding(final String exclude) {
+ for (int i = 0; i < this.stringFields.size(); i++) {
+ final String field = this.stringFields.get(i);
+ if (exclude == null || !exclude.equals(field)) return field;
+ }
+ return null;
+ }
+ }
+}
diff --git a/source/net/yacy/visualization/ArtifactPlotter.java b/source/net/yacy/visualization/ArtifactPlotter.java
new file mode 100644
index 000000000..c15cfe5db
--- /dev/null
+++ b/source/net/yacy/visualization/ArtifactPlotter.java
@@ -0,0 +1,584 @@
+/**
+ * ArtifactPlotter
+ * Copyright 2026 by Michael Peter Christen
+ * First released 11.02.2026 at https://yacy.net
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program in the file lgpl21.txt
+ * If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.yacy.visualization;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+
+/**
+ * Renders artifact envelopes from ArtifactEnvelopeTool into YaCy raster images.
+ * <p>
+ * Supported envelope families:
+ * <ul>
+ * <li>chart artifacts (vega-lite style subset: mark, encoding, data.values)</li>
+ * <li>graph artifacts (spec.nodes/spec.edges using data.id/source/target)</li>
+ * </ul>
+ */
+public class ArtifactPlotter extends RasterPlotter {
+
+ public static final int DEFAULT_WIDTH = 640;
+ public static final int DEFAULT_HEIGHT = 480;
+
+ private static final long COLOR_BACKGROUND = 0xFFFFFFL;
+ private static final long COLOR_AXES = 0x202020L;
+ private static final long COLOR_GRID = 0xE6E6E6L;
+ private static final long COLOR_TEXT = 0x222222L;
+ private static final long COLOR_EDGE = 0x6C7A89L;
+ private static final long COLOR_NODE = 0x2878B5L;
+ private static final long COLOR_NODE_TEXT = 0x111111L;
+
+ private static final long[] PALETTE = new long[] {
+ 0x1F77B4L, 0xFF7F0EL, 0x2CA02CL, 0xD62728L, 0x9467BDL, 0x8C564BL,
+ 0xE377C2L, 0x7F7F7FL, 0xBCBD22L, 0x17BECFL
+ };
+
+ private static final int MARGIN_LEFT = 64;
+ private static final int MARGIN_RIGHT = 24;
+ private static final int MARGIN_TOP = 48;
+ private static final int MARGIN_BOTTOM = 58;
+
+ public ArtifactPlotter() {
+ this(DEFAULT_WIDTH, DEFAULT_HEIGHT);
+ }
+
+ public ArtifactPlotter(final int width, final int height) {
+ super(width, height, DrawMode.MODE_REPLACE, COLOR_BACKGROUND);
+ }
+
+ public static ArtifactPlotter fromEnvelope(final String artifactEnvelopeJson) {
+ final ArtifactPlotter p = new ArtifactPlotter();
+ p.paintEnvelope(artifactEnvelopeJson);
+ return p;
+ }
+
+ public static ArtifactPlotter fromEnvelope(final JSONObject artifactEnvelope) {
+ final ArtifactPlotter p = new ArtifactPlotter();
+ p.paintEnvelope(artifactEnvelope);
+ return p;
+ }
+
+ public void paintEnvelope(final String artifactEnvelopeJson) {
+ if (artifactEnvelopeJson == null || artifactEnvelopeJson.trim().isEmpty()) {
+ drawError("empty artifact envelope");
+ return;
+ }
+ try {
+ final Object parsed = new JSONTokener(artifactEnvelopeJson).nextValue();
+ if (!(parsed instanceof JSONObject)) {
+ drawError("artifact envelope must be JSON object");
+ return;
+ }
+ paintEnvelope((JSONObject) parsed);
+ } catch (final JSONException e) {
+ drawError("invalid JSON envelope");
+ }
+ }
+
+ public void paintEnvelope(final JSONObject artifactEnvelope) {
+ clear();
+ if (artifactEnvelope == null) {
+ drawError("null artifact envelope");
+ return;
+ }
+ final String kind = artifactEnvelope.optString("kind", "");
+ if (!"artifact".equalsIgnoreCase(kind)) {
+ drawError("unsupported kind: " + kind);
+ return;
+ }
+ final String artifactType = artifactEnvelope.optString("artifact_type", "").toLowerCase(Locale.ROOT);
+ final JSONObject spec = artifactEnvelope.optJSONObject("spec");
+ if (spec == null) {
+ drawError("missing spec");
+ return;
+ }
+
+ if ("chart".equals(artifactType)) {
+ paintChart(artifactEnvelope, spec);
+ return;
+ }
+ if ("graph".equals(artifactType)) {
+ paintGraph(artifactEnvelope, spec);
+ return;
+ }
+ drawError("unsupported artifact_type: " + artifactType);
+ }
+
+ private void paintChart(final JSONObject artifact, final JSONObject spec) {
+ final JSONObject data = spec.optJSONObject("data");
+ JSONArray values = data == null ? null : data.optJSONArray("values");
+ if (values == null) values = spec.optJSONArray("values");
+ if (values == null || values.length() == 0) {
+ drawError("chart has no data values");
+ return;
+ }
+
+ final JSONObject encoding = spec.optJSONObject("encoding");
+ final JSONObject xEnc = encoding == null ? null : encoding.optJSONObject("x");
+ final JSONObject yEnc = encoding == null ? null : encoding.optJSONObject("y");
+ final JSONObject cEnc = encoding == null ? null : encoding.optJSONObject("color");
+
+ final String xField = xEnc == null ? null : trimToNull(xEnc.optString("field", null));
+ final String yField = yEnc == null ? null : trimToNull(yEnc.optString("field", null));
+ final String xType = normalizeType(xEnc == null ? null : xEnc.optString("type", "nominal"));
+ final String yType = normalizeType(yEnc == null ? null : yEnc.optString("type", "quantitative"));
+ final String colorField = cEnc == null ? null : trimToNull(cEnc.optString("field", null));
+ final String mark = spec.optString("mark", "line").toLowerCase(Locale.ROOT);
+
+ if (xField == null || yField == null) {
+ drawError("chart encoding requires x/y fields");
+ return;
+ }
+
+ final List<ChartPoint> points = new ArrayList<ChartPoint>();
+ final Map<String, Integer> xCategories = new LinkedHashMap<String, Integer>();
+ final Map<String, Integer> yCategories = new LinkedHashMap<String, Integer>();
+ for (int i = 0; i < values.length(); i++) {
+ final JSONObject row = values.optJSONObject(i);
+ if (row == null) continue;
+ final ChartPoint p = pointFromRow(row, xField, xType, yField, yType, colorField, xCategories, yCategories);
+ if (p != null) points.add(p);
+ }
+ if (points.isEmpty()) {
+ drawError("chart data rows are not plottable");
+ return;
+ }
+
+ final int left = MARGIN_LEFT;
+ final int right = getWidth() - MARGIN_RIGHT;
+ final int top = MARGIN_TOP;
+ final int bottom = getHeight() - MARGIN_BOTTOM;
+ final int plotW = Math.max(1, right - left);
+ final int plotH = Math.max(1, bottom - top);
+
+ final double minX = minX(points);
+ final double maxX = maxX(points);
+ final double minY = minY(points, "bar".equals(mark) || "area".equals(mark));
+ final double maxY = maxY(points);
+ final double safeMaxY = (maxY <= minY) ? minY + 1.0d : maxY;
+ final double safeMaxX = (maxX <= minX) ? minX + 1.0d : maxX;
+ final int baselineY = mapY(0.0d < minY ? minY : 0.0d, minY, safeMaxY, top, bottom, plotH);
+
+ drawChartScaffold(artifact, spec, xField, yField, left, right, top, bottom, minY, safeMaxY);
+
+ final Map<String, Long> seriesColors = assignSeriesColors(points);
+ if ("line".equals(mark) || "area".equals(mark)) {
+ final Map<String, List<ChartPoint>> groups = groupBySeries(points);
+ for (final Map.Entry<String, List<ChartPoint>> entry : groups.entrySet()) {
+ final List<ChartPoint> series = entry.getValue();
+ Collections.sort(series, new Comparator<ChartPoint>() {
+ @Override
+ public int compare(final ChartPoint a, final ChartPoint b) {
+ return Double.compare(a.x, b.x);
+ }
+ });
+ final long color = seriesColors.get(entry.getKey()).longValue();
+ for (int i = 1; i < series.size(); i++) {
+ final ChartPoint p0 = series.get(i - 1);
+ final ChartPoint p1 = series.get(i);
+ final int x0 = mapX(p0.x, minX, safeMaxX, left, plotW);
+ final int y0 = mapY(p0.y, minY, safeMaxY, top, bottom, plotH);
+ final int x1 = mapX(p1.x, minX, safeMaxX, left, plotW);
+ final int y1 = mapY(p1.y, minY, safeMaxY, top, bottom, plotH);
+ setColor(color);
+ line(x0, y0, x1, y1, 100);
+ if ("area".equals(mark)) {
+ line(x1, y1, x1, baselineY, 35);
+ }
+ }
+ for (int i = 0; i < series.size(); i++) {
+ final ChartPoint p = series.get(i);
+ final int x = mapX(p.x, minX, safeMaxX, left, plotW);
+ final int y = mapY(p.y, minY, safeMaxY, top, bottom, plotH);
+ setColor(color);
+ dot(x, y, 2, true, 100);
+ }
+ }
+ } else if ("point".equals(mark)) {
+ for (int i = 0; i < points.size(); i++) {
+ final ChartPoint p = points.get(i);
+ final int x = mapX(p.x, minX, safeMaxX, left, plotW);
+ final int y = mapY(p.y, minY, safeMaxY, top, bottom, plotH);
+ final long color = seriesColors.get(p.series).longValue();
+ setColor(color);
+ dot(x, y, 3, true, 100);
+ }
+ } else { // bar as default fallback for unknown marks
+ final int count = Math.max(1, points.size());
+ final int barWidth = Math.max(1, Math.min(22, (int) (plotW / (double) Math.max(2, count))));
+ for (int i = 0; i < points.size(); i++) {
+ final ChartPoint p = points.get(i);
+ final int x = mapX(p.x, minX, safeMaxX, left, plotW);
+ final int y = mapY(p.y, minY, safeMaxY, top, bottom, plotH);
+ final long color = seriesColors.get(p.series).longValue();
+ setColor(color);
+ for (int dx = -barWidth / 2; dx <= barWidth / 2; dx++) {
+ line(x + dx, baselineY, x + dx, y, 100);
+ }
+ }
+ }
+
+ drawSeriesLegend(points, seriesColors, right, top + 6);
+ drawNominalTicks(xType, xCategories, left, top, bottom, plotW);
+ }
+
+ private void paintGraph(final JSONObject artifact, final JSONObject spec) {
+ final JSONArray nodes = spec.optJSONArray("nodes");
+ final JSONArray edges = spec.optJSONArray("edges");
+ if (nodes == null || edges == null) {
+ drawError("graph requires nodes and edges");
+ return;
+ }
+
+ final Set<String> nodeIds = new LinkedHashSet<String>();
+ for (int i = 0; i < nodes.length(); i++) {
+ final JSONObject node = nodes.optJSONObject(i);
+ if (node == null) continue;
+ final JSONObject data = node.optJSONObject("data");
+ final String id = data == null ? null : trimToNull(data.optString("id", null));
+ if (id != null) nodeIds.add(id);
+ }
+ for (int i = 0; i < edges.length(); i++) {
+ final JSONObject edge = edges.optJSONObject(i);
+ if (edge == null) continue;
+ final JSONObject data = edge.optJSONObject("data");
+ if (data == null) continue;
+ final String source = trimToNull(data.optString("source", null));
+ final String target = trimToNull(data.optString("target", null));
+ if (source != null) nodeIds.add(source);
+ if (target != null) nodeIds.add(target);
+ }
+ if (nodeIds.isEmpty()) {
+ drawError("graph has no nodes");
+ return;
+ }
+
+ final int left = 30;
+ final int right = getWidth() - 30;
+ final int top = 40;
+ final int bottom = getHeight() - 24;
+ final int cx = (left + right) / 2;
+ final int cy = (top + bottom) / 2;
+ final int radius = Math.max(20, Math.min(right - left, bottom - top) / 2 - 26);
+
+ final List<String> ordered = new ArrayList<String>(nodeIds);
+ Collections.sort(ordered);
+ final Map<String, NodePos> positions = new LinkedHashMap<String, NodePos>();
+ for (int i = 0; i < ordered.size(); i++) {
+ final String id = ordered.get(i);
+ final double angle = (Math.PI * 2.0d * i) / Math.max(1, ordered.size());
+ final int x = cx + (int) Math.round(Math.cos(angle) * radius);
+ final int y = cy + (int) Math.round(Math.sin(angle) * radius);
+ positions.put(id, new NodePos(x, y));
+ }
+
+ drawGraphHeader(artifact, spec);
+ setColor(COLOR_EDGE);
+ for (int i = 0; i < edges.length(); i++) {
+ final JSONObject edge = edges.optJSONObject(i);
+ if (edge == null) continue;
+ final JSONObject data = edge.optJSONObject("data");
+ if (data == null) continue;
+ final String source = trimToNull(data.optString("source", null));
+ final String target = trimToNull(data.optString("target", null));
+ if (source == null || target == null) continue;
+ final NodePos from = positions.get(source);
+ final NodePos to = positions.get(target);
+ if (from == null || to == null) continue;
+ lineArrow(from.x, from.y, to.x, to.y, 6, 8, COLOR_EDGE, COLOR_EDGE);
+ }
+
+ for (int i = 0; i < ordered.size(); i++) {
+ final String id = ordered.get(i);
+ final NodePos p = positions.get(id);
+ setColor(COLOR_NODE);
+ dot(p.x, p.y, 7, true, 100);
+ setColor(COLOR_NODE_TEXT);
+ PrintTool.print6(this, p.x, p.y + 16, 0, truncate(id, 14), 0, 90, false, false);
+ }
+ }
+
+ private void drawChartScaffold(final JSONObject artifact, final JSONObject spec, final String xField, final String yField,
+ final int left, final int right, final int top, final int bottom, final double minY, final double maxY) {
+ final String title = trimToNull(spec.optString("title", null));
+ setColor(COLOR_TEXT);
+ PrintTool.print6(this, getWidth() / 2, 14, 0, title == null ? "ARTIFACT CHART" : title.toUpperCase(Locale.ROOT), 0, 90, true, false);
+
+ setColor(COLOR_GRID);
+ for (int i = 1; i < 6; i++) {
+ final int y = top + ((bottom - top) * i / 6);
+ line(left, y, right, y, 100);
+ }
+
+ setColor(COLOR_AXES);
+ line(left, top, left, bottom, 100);
+ line(left, bottom, right, bottom, 100);
+
+ setColor(COLOR_TEXT);
+ PrintTool.print6(this, left - 8, top - 2, 90, yField.toUpperCase(Locale.ROOT), 1, 80, false, false);
+ PrintTool.print6(this, (left + right) / 2, getHeight() - 12, 0, xField.toUpperCase(Locale.ROOT), 0, 80, false, false);
+ PrintTool.print6(this, left - 4, bottom + 12, 0, formatTick(minY), 1, 70, false, false);
+ PrintTool.print6(this, left - 4, top + 6, 0, formatTick(maxY), 1, 70, false, false);
+ }
+
+ private void drawGraphHeader(final JSONObject artifact, final JSONObject spec) {
+ final String title = trimToNull(spec.optString("title", null));
+ setColor(COLOR_TEXT);
+ PrintTool.print6(this, getWidth() / 2, 14, 0, title == null ? "ARTIFACT GRAPH" : title.toUpperCase(Locale.ROOT), 0, 90, true, false);
+ }
+
+ private void drawSeriesLegend(final List<ChartPoint> points, final Map<String, Long> seriesColors, final int right, final int startY) {
+ if (seriesColors.size() <= 1) return;
+ int y = startY;
+ final List<String> keys = new ArrayList<String>(seriesColors.keySet());
+ for (int i = 0; i < keys.size() && i < 8; i++) {
+ final String key = keys.get(i);
+ final long c = seriesColors.get(key).longValue();
+ setColor(c);
+ dot(right - 6, y, 3, true, 100);
+ setColor(COLOR_TEXT);
+ PrintTool.print6(this, right - 12, y + 2, 0, truncate(key.toUpperCase(Locale.ROOT), 16), 1, 70, false, false);
+ y += 12;
+ }
+ }
+
+ private void drawNominalTicks(final String xType, final Map<String, Integer> xCategories,
+ final int left, final int top, final int bottom, final int plotW) {
+ if (!"nominal".equals(xType) && !"ordinal".equals(xType)) return;
+ if (xCategories.isEmpty()) return;
+ final int n = xCategories.size();
+ final int maxLabels = 8;
+ int i = 0;
+ for (final Map.Entry<String, Integer> entry : xCategories.entrySet()) {
+ if (n > maxLabels && i % Math.max(1, n / maxLabels) != 0) {
+ i++;
+ continue;
+ }
+ final double xValue = entry.getValue().doubleValue();
+ final int x = left + (int) Math.round((xValue / Math.max(1.0d, n - 1.0d)) * plotW);
+ setColor(COLOR_AXES);
+ line(x, bottom, x, bottom + 4, 100);
+ setColor(COLOR_TEXT);
+ PrintTool.print6(this, x, bottom + 14, 0, truncate(entry.getKey().toUpperCase(Locale.ROOT), 8), 0, 60, false, false);
+ i++;
+ }
+ }
+
+ private ChartPoint pointFromRow(final JSONObject row, final String xField, final String xType, final String yField, final String yType,
+ final String colorField, final Map<String, Integer> xCategories, final Map<String, Integer> yCategories) {
+ if (row == null) return null;
+ final Object xRaw = row.opt(xField);
+ final Object yRaw = row.opt(yField);
+ if (xRaw == null || xRaw == JSONObject.NULL || yRaw == null || yRaw == JSONObject.NULL) return null;
+
+ final Double xValue = valueForType(xRaw, xType, xCategories);
+ final Double yValue = valueForType(yRaw, yType, yCategories);
+ if (xValue == null || yValue == null) return null;
+
+ final String series;
+ if (colorField != null && row.has(colorField) && row.opt(colorField) != JSONObject.NULL) {
+ series = String.valueOf(row.opt(colorField));
+ } else {
+ series = "_default";
+ }
+ return new ChartPoint(xValue.doubleValue(), yValue.doubleValue(), series);
+ }
+
+ private static Double valueForType(final Object raw, final String type, final Map<String, Integer> categories) {
+ if ("temporal".equals(type)) return parseTemporal(raw);
+ if ("quantitative".equals(type)) return parseDouble(raw);
+ if ("nominal".equals(type) || "ordinal".equals(type)) {
+ final String key = String.valueOf(raw);
+ Integer idx = categories.get(key);
+ if (idx == null) {
+ idx = Integer.valueOf(categories.size());
+ categories.put(key, idx);
+ }
+ return Double.valueOf(idx.doubleValue());
+ }
+ final Double d = parseDouble(raw);
+ if (d != null) return d;
+ return parseTemporal(raw);
+ }
+
+ private static Double parseTemporal(final Object raw) {
+ if (raw == null || raw == JSONObject.NULL) return null;
+ if (raw instanceof Number) return Double.valueOf(((Number) raw).doubleValue());
+ final String s = String.valueOf(raw).trim();
+ if (s.isEmpty()) return null;
+ try {
+ return Double.valueOf(Instant.parse(s).toEpochMilli());
+ } catch (final DateTimeParseException e) {
+ // continue
+ }
+ try {
+ return Double.valueOf(LocalDate.parse(s).toEpochDay());
+ } catch (final DateTimeParseException e) {
+ return parseDouble(s);
+ }
+ }
+
+ private static Double parseDouble(final Object raw) {
+ if (raw == null || raw == JSONObject.NULL) return null;
+ if (raw instanceof Number) return Double.valueOf(((Number) raw).doubleValue());
+ final String s = String.valueOf(raw).trim();
+ if (s.isEmpty()) return null;
+ try {
+ return Double.valueOf(Double.parseDouble(s));
+ } catch (final NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private static String normalizeType(final String type) {
+ final String t = trimToNull(type);
+ if (t == null) return "nominal";
+ final String lower = t.toLowerCase(Locale.ROOT);
+ if ("temporal".equals(lower) || "quantitative".equals(lower) || "nominal".equals(lower) || "ordinal".equals(lower)) return lower;
+ return "nominal";
+ }
+
+ private static String trimToNull(final String s) {
+ if (s == null) return null;
+ final String t = s.trim();
+ return t.isEmpty() ? null : t;
+ }
+
+ private static int mapX(final double value, final double minX, final double maxX, final int left, final int plotW) {
+ final double ratio = (value - minX) / Math.max(1e-12d, maxX - minX);
+ return left + (int) Math.round(ratio * plotW);
+ }
+
+ private static int mapY(final double value, final double minY, final double maxY, final int top, final int bottom, final int plotH) {
+ final double ratio = (value - minY) / Math.max(1e-12d, maxY - minY);
+ return bottom - (int) Math.round(ratio * plotH);
+ }
+
+ private static double minX(final List<ChartPoint> points) {
+ double min = Double.POSITIVE_INFINITY;
+ for (int i = 0; i < points.size(); i++) min = Math.min(min, points.get(i).x);
+ return min;
+ }
+
+ private static double maxX(final List<ChartPoint> points) {
+ double max = Double.NEGATIVE_INFINITY;
+ for (int i = 0; i < points.size(); i++) max = Math.max(max, points.get(i).x);
+ return max;
+ }
+
+ private static double minY(final List<ChartPoint> points, final boolean includeZeroBaseline) {
+ double min = Double.POSITIVE_INFINITY;
+ for (int i = 0; i < points.size(); i++) min = Math.min(min, points.get(i).y);
+ if (includeZeroBaseline) min = Math.min(0.0d, min);
+ return min;
+ }
+
+ private static double maxY(final List<ChartPoint> points) {
+ double max = Double.NEGATIVE_INFINITY;
+ for (int i = 0; i < points.size(); i++) max = Math.max(max, points.get(i).y);
+ return max;
+ }
+
+ private static Map<String, List<ChartPoint>> groupBySeries(final List<ChartPoint> points) {
+ final Map<String, List<ChartPoint>> grouped = new LinkedHashMap<String, List<ChartPoint>>();
+ for (int i = 0; i < points.size(); i++) {
+ final ChartPoint p = points.get(i);
+ List<ChartPoint> list = grouped.get(p.series);
+ if (list == null) {
+ list = new ArrayList<ChartPoint>();
+ grouped.put(p.series, list);
+ }
+ list.add(p);
+ }
+ return grouped;
+ }
+
+ private static Map<String, Long> assignSeriesColors(final List<ChartPoint> points) {
+ final Map<String, Long> colors = new LinkedHashMap<String, Long>();
+ int i = 0;
+ for (int p = 0; p < points.size(); p++) {
+ final String key = points.get(p).series;
+ if (!colors.containsKey(key)) {
+ colors.put(key, Long.valueOf(PALETTE[i % PALETTE.length]));
+ i++;
+ }
+ }
+ if (colors.isEmpty()) colors.put("_default", Long.valueOf(PALETTE[0]));
+ return colors;
+ }
+
+ private void drawError(final String message) {
+ clear();
+ setColor(0xBB2222L);
+ PrintTool.print6(this, getWidth() / 2, getHeight() / 2 - 6, 0, "ARTIFACT RENDER ERROR", 0, 100, true, false);
+ setColor(0x222222L);
+ PrintTool.print6(this, getWidth() / 2, getHeight() / 2 + 10, 0, truncate(message == null ? "unknown error" : message, 50).toUpperCase(Locale.ROOT), 0, 80, false, false);
+ }
+
+ private static String truncate(final String s, final int maxLen) {
+ if (s == null) return "";
+ if (s.length() <= maxLen) return s;
+ if (maxLen <= 3) return s.substring(0, Math.max(0, maxLen));
+ return s.substring(0, maxLen - 3) + "...";
+ }
+
+ private static String formatTick(final double v) {
+ if (Math.abs(v) >= 1000.0d || Math.abs(v) < 0.01d) return String.format(Locale.ROOT, "%.2e", v);
+ if (Math.abs(v - Math.rint(v)) < 1e-9d) return String.valueOf((long) Math.rint(v));
+ return String.format(Locale.ROOT, "%.2f", v);
+ }
+
+ private static final class ChartPoint {
+ final double x;
+ final double y;
+ final String series;
+
+ ChartPoint(final double x, final double y, final String series) {
+ this.x = x;
+ this.y = y;
+ this.series = series == null ? "_default" : series;
+ }
+ }
+
+ private static final class NodePos {
+ final int x;
+ final int y;
+
+ NodePos(final int x, final int y) {
+ this.x = x;
+ this.y = y;
+ }
+ }
+}