Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public class EChartsSerializer {
public EChartsSerializer(EChartsTypeAdapter<?>... typeAdapters) {
GsonBuilder gsonBuilder = new GsonBuilder().disableHtmlEscaping()
.registerTypeAdapter(markArea2DDataItemAdapter.getType(), markArea2DDataItemAdapter)
.registerTypeAdapter(markLine2DDataItemAdapter.getType(), markLine2DDataItemAdapter);
.registerTypeAdapter(markLine2DDataItemAdapter.getType(), markLine2DDataItemAdapter)
.registerTypeAdapter(JsFunction.class, new JsFunctionTypeAdapter());
for (EChartsTypeAdapter<?> typeAdapter : typeAdapters) {
gsonBuilder.registerTypeAdapter(typeAdapter.getType(), typeAdapter);
}
Expand Down
71 changes: 71 additions & 0 deletions src/main/java/org/icepear/echarts/serializer/JsFunction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package org.icepear.echarts.serializer;

import java.io.Serializable;
import java.util.Objects;

/**
* Wraps a JavaScript function literal so it can be embedded into the option
* JSON unquoted — i.e. as a real function, not a string.
*
* <p>ECharts accepts either a string template ({@code "{b}: {c}"}) <em>or</em> a
* JS function for fields like {@code tooltip.formatter}, {@code label.formatter},
* {@code axisLabel.formatter}, {@code visualMap.formatter}, etc. The string form
* already works through any {@code setFormatter(String)} setter; this class
* unlocks the function form.
*
* <p>Pass it through any setter that accepts {@code Object}:
* <pre>{@code
* tooltip.setFormatter(new JsFunction(
* "function (params) {"
* + " return '<b>' + params.name + '</b>: ' + params.value + ' (' + params.percent + '%)';"
* + "}"));
* }</pre>
*
* <p><strong>Important:</strong> a {@code JsFunction} is meant to be embedded in
* an HTML/JS context (e.g. through {@link org.icepear.echarts.render.Engine}).
* The serialized output is valid JavaScript but <em>not</em> strictly valid
* JSON, so don't feed it into a JSON parser. The body is emitted verbatim — the
* caller is responsible for its JS syntax.
*/
public final class JsFunction implements Serializable {

private static final long serialVersionUID = 1L;

private final String body;

public JsFunction(String body) {
if (body == null) {
throw new IllegalArgumentException("JsFunction body must not be null");
}
this.body = body;
}

public static JsFunction of(String body) {
return new JsFunction(body);
}

public String getBody() {
return body;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof JsFunction)) {
return false;
}
return Objects.equals(body, ((JsFunction) o).body);
}

@Override
public int hashCode() {
return Objects.hashCode(body);
}

@Override
public String toString() {
return "JsFunction{" + body + "}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.icepear.echarts.serializer;

import java.io.IOException;

import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;

/**
* Low-level Gson adapter that writes a {@link JsFunction}'s body straight into
* the output stream as raw JSON-text — i.e. unquoted. This is what makes the
* function survive into the rendered HTML as a real JS callable instead of a
* quoted string.
*
* <p>Implemented as a {@link TypeAdapter} (not a {@code JsonSerializer}) because
* only the streaming API exposes {@link JsonWriter#jsonValue(String)}, which
* permits raw text. {@code JsonSerializer} can only return a {@code JsonElement}
* — and a string element would always come out quoted.
*
* <p>Read direction is best-effort: if anyone deserializes JS-function-bearing
* JSON back into Java (uncommon), they get a {@code JsFunction} wrapping the
* raw text. The library only round-trips for tests, so this is rarely exercised.
*/
final class JsFunctionTypeAdapter extends TypeAdapter<JsFunction> {

@Override
public void write(JsonWriter out, JsFunction value) throws IOException {
if (value == null) {
out.nullValue();
return;
}
out.jsonValue(value.getBody());
}

@Override
public JsFunction read(JsonReader in) throws IOException {
return new JsFunction(in.nextString());
}
}
89 changes: 89 additions & 0 deletions src/test/java/org/icepear/echarts/demo/JsFunctionTooltipDemo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package org.icepear.echarts.demo;

import java.io.FileWriter;
import java.io.Writer;

import org.icepear.echarts.Pie;
import org.icepear.echarts.charts.pie.PieDataItem;
import org.icepear.echarts.charts.pie.PieSeries;
import org.icepear.echarts.components.title.Title;
import org.icepear.echarts.components.tooltip.Tooltip;
import org.icepear.echarts.serializer.EChartsSerializer;
import org.icepear.echarts.serializer.JsFunction;
import org.junit.Test;

/**
* Local-only demo: writes /tmp/js-function-tooltip-demo.html.
*
* Hover over a slice to see the rich tooltip rendered by the JS function
* built in Java via {@link JsFunction}. This is the visual proof for issue #86.
*
* Run: mvn test -Dtest=JsFunctionTooltipDemo
* Then: open /tmp/js-function-tooltip-demo.html
*/
public class JsFunctionTooltipDemo {

@Test
public void writeDemoHtml() throws Exception {
// The exact JS shape the issue requested — multi-line HTML built from
// params.marker / params.name / params.value / params.percent.
JsFunction tooltipFormatter = new JsFunction(
"function (params) {\n"
+ " return ''\n"
+ " + '<div class=\"tooltip-content\">'\n"
+ " + ' ' + params.marker\n"
+ " + ' <p class=\"tooltip-category\">' + params.name + '</p>'\n"
+ " + ' <p class=\"tooltip-value\">' + params.value.toLocaleString() + '</p>'\n"
+ " + ' <p class=\"tooltip-currency\">MDL</p>'\n"
+ " + ' <div class=\"tooltip-percent\">' + params.percent + '%</div>'\n"
+ " + '</div>';\n"
+ "}");

Pie chart = new Pie()
.setTitle(new Title()
.setText("Quarterly Revenue by Region")
.setLeft("center"))
.setTooltip(new Tooltip()
.setTrigger("item")
.setBackgroundColor("rgba(20, 24, 32, 0.92)")
.setBorderColor("#3a4a66")
.setFormatter((Object) tooltipFormatter))
.setLegend()
.addSeries(new PieSeries()
.setName("Revenue")
.setRadius(new String[] { "40%", "65%" })
.setData(new PieDataItem[] {
new PieDataItem().setName("North").setValue(1248000),
new PieDataItem().setName("South").setValue(958500),
new PieDataItem().setName("East").setValue(742300),
new PieDataItem().setName("West").setValue(1102400),
new PieDataItem().setName("Central").setValue(615000)
}));

String optionJson = new EChartsSerializer().toJson(chart.getOption());

String html = "<!doctype html><html><head><meta charset='utf-8'>"
+ "<title>JsFunction Tooltip Demo (issue #86)</title>"
+ "<script src='https://cdnjs.cloudflare.com/ajax/libs/echarts/5.4.3/echarts.min.js'></script>"
+ "<style>"
+ " html, body, #chart { margin: 0; width: 100%; height: 100vh; background: #0b1220; }"
+ " .tooltip-content { color: #fff; font-family: -apple-system, sans-serif; min-width: 180px; }"
+ " .tooltip-content p { margin: 4px 0; }"
+ " .tooltip-category { font-size: 14px; font-weight: 600; color: #e8eef9; }"
+ " .tooltip-value { font-size: 22px; font-weight: 700; color: #ffd166; }"
+ " .tooltip-currency { font-size: 11px; color: #8aa0c2; letter-spacing: .1em; }"
+ " .tooltip-percent { margin-top: 6px; padding: 3px 8px; background: #2a4a82;"
+ " border-radius: 12px; display: inline-block; font-size: 12px; }"
+ "</style></head><body><div id='chart'></div>"
+ "<script>"
+ " const chart = echarts.init(document.getElementById('chart'), 'dark');"
+ " chart.setOption(" + optionJson + ");"
+ " window.addEventListener('resize', () => chart.resize());"
+ "</script></body></html>";

try (Writer w = new FileWriter("/tmp/js-function-tooltip-demo.html")) {
w.write(html);
}
System.out.println("\n>>> Wrote /tmp/js-function-tooltip-demo.html — open it and hover over a slice.\n");
}
}
Loading
Loading