package de.geolykt.starloader.api.gui.graph;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentLinkedDeque;
import javax.annotation.Nonnegative;
import org.jetbrains.annotations.ApiStatus.AvailableSince;
import org.jetbrains.annotations.ApiStatus.Internal;
import org.jetbrains.annotations.ApiStatus.ScheduledForRemoval;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import de.geolykt.starloader.DeprecatedSince;
import de.geolykt.starloader.api.NamespacedKey;
import de.geolykt.starloader.api.registry.Registry;
import de.geolykt.starloader.api.serial.Codec;
import de.geolykt.starloader.api.serial.Decoder;
import de.geolykt.starloader.api.serial.Encoder;
import de.geolykt.starloader.api.serial.MissingDecoderException;
import de.geolykt.starloader.impl.util.LEB128;
/**
* An implementation of {@link ChartData} that allows to incrementally add nodes to the
* chart. These nodes are then converted to Edges.
* While the main goal of this implementation was to have relatively low runtime complexities,
* storing all too much data may produce issues with visualisation.
*
*
This class has a built-in {@link Codec} instance registered. Please note that
* this {@link Codec} is incapable of (de-)serialising subclasses of {@link RollingChartData}
* for technical reasons. Further, serialisation results in any nodes added to this graph but
* which haven't been committed via a {@link #incrementPosition()} call to be lost.
* Further, the next {@link #incrementPosition()} call after de-serialisation will be a NOP.
*
* @param The type used for the vertices/nodes within the graph.
* @since 1.5.0
* @apiNote Starting from 2.0.0-a20251226 instances of this class support asynchronous
* reads and serialisation. Older versions cannot be serialised, nor do they
* support asynchronous {@link #getEdges()} calls.
*/
@AvailableSince("1.5.0")
public class RollingChartData implements ChartData {
// TODO allow for clearing and SerDe
@NotNull
private Map> currentNodes = new HashMap<>();
/**
* The current position of the chart.
*
* Or in other words, the read tail position.
*/
private int currentPosition = -1;
/**
* The raw edges stored by the chart.
*/
@NotNull
private final Deque> edges = new ConcurrentLinkedDeque<>();
/**
* The highest encountered value.
*/
private int maxValue = 0;
@NotNull
private Map> previousNodes = new HashMap<>();
/**
* The read head position.
*/
private int readHead = 0;
private boolean ignoreIncrement = false;
/**
* For how many positions a value inserted by {@link #addNode(Object, int)} should last.
* Each period begins with a {@link #incrementPosition()}.
*/
private int validityPeriod;
/**
* Creates a new instance of the class.
* The validity period must be equal or larger than 4.
*
* @param validityPeriod For how many positions a value inserted by {@link #addNode(Object, int)} should last. Each period begins with a {@link #incrementPosition()}.
* @since 1.5.0
*/
@Contract(pure = true)
@AvailableSince("1.5.0")
public RollingChartData(@Nonnegative int validityPeriod) {
if (validityPeriod < 4) {
throw new IllegalArgumentException("The validity period must be over 3 due to caching reasons. (and it does not make any sense to have it that low)");
}
this.validityPeriod = validityPeriod;
}
/**
* Adds a node with the given value to the chart.
* {@link #incrementPosition()} must be called before adding the first node to the chart,
* and likewise must be called whenever the value of a node must be updated.
*
* @param node The node.
* @param value The value of the node.
* @throws IllegalStateException if a node was inserted twice into the chart without calling {@link #incrementPosition()} in between.
* @since 1.5.0
* @implNote This method must not be called concurrently to {@link #incrementPosition()}. However, concurrent calls
* to {@link #getEdges()} are safe.
*/
@AvailableSince("1.5.0")
@Contract(pure = false, mutates = "this")
public synchronized void addNode(@NotNull T node, int value) {
this.maxValue = Math.max(this.maxValue, value);
if (this.currentPosition < 1) {
if (this.currentPosition != 0) {
throw new IllegalStateException("Illegal position: " + this.currentPosition + ". Did you call .incrementPosition?");
}
} else {
ValueEdge lastEdge = this.previousNodes.get(node);
if (lastEdge != null && lastEdge.vertex2Position == this.currentPosition - 1) {
lastEdge = new ValueEdge<>(node, lastEdge.vertex2Value, this.currentPosition - 1, node, value, this.currentPosition);
} else {
lastEdge = new ValueEdge<>(node, 0, this.currentPosition - 1, node, value, this.currentPosition);
}
this.edges.addLast(lastEdge);
if (this.currentNodes.put(node, lastEdge) != null) {
throw new IllegalStateException("Partially overwrote an edge (did you forget to call .incrementPosition?).");
}
}
}
/**
* Obtains the current position of the chart, or in other words, how many times {@link #incrementPosition()}
* has been called, minus 1.
*
* A value of {@code -1} means that {@link #incrementPosition()} was never called, a value of {@code 0}
* that it was called once, {@code 1} twice, etc.
*
*
In other words, this represents the position of the read tail, or the most recent time
* value returned by {@link #getEdges()}.
*
*
Should the value returned by this method be {@code -1}, then a call to {@link #getEdges()}
* will fail.
*
* @return The position of the read tail.
* @since 1.5.0
* @deprecated For API consumers, the value returned by this method has no meaning.
*/
@AvailableSince("1.5.0")
@Contract(pure = true)
@Deprecated
@ScheduledForRemoval(inVersion = "3.0.0")
@DeprecatedSince("2.0.0-a20251222")
public int getCurrentPositon() {
return this.currentPosition;
}
/**
* {@inheritDoc}
*
* @implSpec Since 2.0.0-a20251222, the returned collection is unmodifiable and will not be modified by another thread.
* @implNote From 1.5.0 to 2.0.0-a20251221.1 (inclusive), this method had a bug in that the vertex position values could
* go outside the bounds defined through the constructor.
* @implNote Although versions before 2.0.0-a20251226 might claim that this method is thread-safe, this is
* actually not the case.
*/
@SuppressWarnings("null")
@Override
@NotNull
@Unmodifiable
@Contract(pure = true)
@AvailableSince("1.5.0")
public Collection> getEdges() {
List> graphEdges = new ArrayList<>();
for (ValueEdge edge : this.edges) {
graphEdges.add(new ValueEdge<>(edge.vertex1, edge.vertex1Value, edge.vertex1Position - this.readHead, edge.vertex2, edge.vertex2Value, edge.vertex2Position - this.readHead));
}
return Collections.unmodifiableCollection(graphEdges);
}
@Override
@Contract(pure = true)
public int getHeight() {
return this.maxValue;
}
@Override
@Contract(pure = true)
public int getWidth() {
return this.currentPosition - this.readHead;
}
/**
* Increments the position of the rollover chart and removes edges that are outside the defined validity period.
*
* This method may not be called at the same time as {@link #addNode(Object, int)}.
*
* @since 1.5.0
*/
@AvailableSince("1.5.0")
@Contract(pure = false, mutates = "this")
public synchronized void incrementPosition() {
if (this.ignoreIncrement) {
this.ignoreIncrement = false;
return;
}
for (T node : this.previousNodes.keySet()) {
if (!this.currentNodes.containsKey(node)) {
node = Objects.requireNonNull(node);
this.edges.add(new ValueEdge<>(node, this.previousNodes.get(node).vertex1Value, this.currentPosition - 1, node, 0, this.currentPosition));
}
}
Map> ret = this.previousNodes;
this.previousNodes = this.currentNodes;
this.currentNodes = ret;
this.currentNodes.clear();
this.currentPosition++;
ValueEdge edge = this.edges.peekFirst();
int minPosition = this.currentPosition - this.validityPeriod;
if (edge != null && edge.vertex1Position < minPosition) {
this.edges.removeFirst();
for (Iterator> edgeIterator = this.edges.iterator(); edgeIterator.hasNext();) {
edge = edgeIterator.next();
if (edge.vertex1Position < minPosition) {
edgeIterator.remove();
} else {
break;
}
}
}
this.readHead = Math.max(0, this.currentPosition - this.validityPeriod);
}
@Internal
@Contract(pure = false, mutates = "this")
@AvailableSince("2.0.0-a20251223")
public void serialDecode(@NotNull DataInputStream dataIn) throws IOException {
if (this.currentPosition >= 0) {
throw new IllegalStateException("RollingChartData.serialDecode() may only be called on newly created RollingChartData objects.");
}
int version = dataIn.read();
if (version != 0) {
throw new IOException("Unexpected version. Expected 0, got " + version);
}
this.ignoreIncrement = true;
this.validityPeriod = LEB128.decodeUnsigned(dataIn);
this.readHead = LEB128.decodeUnsigned(dataIn);
this.currentPosition = LEB128.decodeUnsigned(dataIn);
int edgeCount = LEB128.decodeUnsigned(dataIn);
List> edgeProtos = new ArrayList<>(edgeCount);
int oidMax = -1;
while (edgeCount-- != 0) {
int pos1 = LEB128.decodeUnsigned(dataIn);
int val1 = dataIn.readInt();
int pos2 = LEB128.decodeUnsigned(dataIn);
int val2 = dataIn.readInt();
int oid = LEB128.decodeUnsigned(dataIn);
oidMax = Math.max(oid, oidMax);
edgeProtos.add(new ValueEdge<>(oid, val1, pos1, oid, val2, pos2));
this.maxValue = Math.max(this.maxValue, Math.max(val1, val2));
}
@SuppressWarnings("unchecked")
T[] objects = (T[]) new Object[oidMax + 1];
Decoder decoder = null;
for (int oid = 0; oid <= oidMax; oid++) {
int nslen = LEB128.decodeUnsigned(dataIn);
if (nslen != 0) {
byte[] nsdata = new byte[nslen];
dataIn.readFully(nsdata);
byte[] keydata = new byte[LEB128.decodeUnsigned(dataIn)];
dataIn.readFully(keydata);
String namespace = new String(nsdata, StandardCharsets.UTF_8);
String key = new String(keydata, StandardCharsets.UTF_8);
NamespacedKey decoderKey = NamespacedKey.fromString(namespace, key);
try {
decoder = Registry.CODECS.requireDecoder(decoderKey);
} catch (MissingDecoderException e) {
throw new IOException(e);
}
} else if (decoder == null) {
throw new IOException("No decoder specified.");
}
byte[] data = new byte[LEB128.decodeUnsigned(dataIn)];
dataIn.readFully(data);
objects[oid] = Objects.requireNonNull(decoder.decode(data), "Decoded object may not be null");
}
for (ValueEdge proto : edgeProtos) {
T vertex = objects[proto.vertex1];
assert vertex != null;
this.edges.add(new ValueEdge<>(vertex, proto.vertex1Value, proto.vertex1Position, vertex, proto.vertex2Value, proto.vertex2Position));
}
}
@Internal
@Contract(pure = true)
@AvailableSince("2.0.0-a20251223")
public synchronized byte @NotNull[] serialEncode() throws IOException {
Map<@NotNull T, Integer> idLookup = new LinkedHashMap<>();
Collection> edges = this.edges;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dataOut = new DataOutputStream(baos)) {
dataOut.write(0); // Version
LEB128.encodeUnsigned(this.validityPeriod, dataOut);
LEB128.encodeUnsigned(this.readHead, dataOut);
LEB128.encodeUnsigned(this.currentPosition, dataOut);
LEB128.encodeUnsigned(edges.size(), dataOut);
for (ValueEdge edge : edges) {
assert edge.vertex1 == edge.vertex2;
assert edge.vertex1Position >= 0;
assert edge.vertex2Position >= 0;
LEB128.encodeUnsigned(edge.vertex1Position, dataOut);
dataOut.writeInt(edge.vertex1Value);
LEB128.encodeUnsigned(edge.vertex2Position, dataOut);
dataOut.writeInt(edge.vertex2Value);
int objectId = idLookup.compute(edge.vertex1, (key, value) -> {
if (value == null) {
return idLookup.size();
} else {
return value;
}
});
LEB128.encodeUnsigned(objectId, dataOut);
}
Encoder encoder = null;
for (T value : idLookup.keySet()) {
if (encoder == null || !encoder.canEncode(value)) {
encoder = Registry.CODECS.getEncoder(value);
if (encoder == null) {
throw new IOException("Cannot serialize object of class '" + value.getClass().getName() + "': No encoder for object.");
}
byte[] encoderKeyNamespace = encoder.getEncodingKey().getNamespace().getBytes(StandardCharsets.UTF_8);
if (encoderKeyNamespace.length == 0) {
throw new IllegalStateException("Encoder key without namespace? " + encoder.getEncodingKey() + " (a " + encoder.getClass().getName() + ")");
}
LEB128.encodeUnsigned(encoderKeyNamespace.length, dataOut);
dataOut.write(encoderKeyNamespace);
byte[] encoderKeyKey = encoder.getEncodingKey().getKey().getBytes(StandardCharsets.UTF_8);
LEB128.encodeUnsigned(encoderKeyKey.length, dataOut);
dataOut.write(encoderKeyKey);
} else {
dataOut.write(0); // Keep active encoder instance
}
byte[] data = encoder.encode(value);
LEB128.encodeUnsigned(data.length, dataOut);
dataOut.write(data);
}
return baos.toByteArray();
}
}
}