Переглянути джерело

feat(exam-sprint): 优化成果报告柱状图坐标轴

金逸霄 2 тижнів тому
батько
коміт
3f29ed8856

+ 2 - 0
.gitattributes

@@ -0,0 +1,2 @@
+*.sh text eol=lf
+**/*.sh text eol=lf

+ 75 - 15
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilder.java

@@ -16,41 +16,101 @@ public class AchievementExamSprintReportSvgChartBuilder {
                                      String beforeText,
                                      String afterLabel,
                                      double afterValue,
-                                     String afterText,
-                                     String fillColor) {
+                                      String afterText,
+                                      String fillColor) {
         double maxValue = Math.max(Math.max(beforeValue, afterValue), 1d);
-        int beforeHeight = barHeight(beforeValue, maxValue);
-        int afterHeight = barHeight(afterValue, maxValue);
+        int axisLeft = 58;
+        int axisBottom = 180;
+        int axisTop = 36;
+        int axisRight = 324;
+        int beforeX = 118;
+        int afterX = 222;
+        int barWidth = 58;
+        int plotHeight = axisBottom - axisTop;
+        int beforeHeight = barHeight(beforeValue, maxValue, plotHeight);
+        int afterHeight = barHeight(afterValue, maxValue, plotHeight);
 
         return new StringBuilder()
                 .append("<svg class='achievement-bar-chart ").append(safeCssClass(cssClass))
                 .append("' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='")
                 .append(escape(ariaLabel)).append("'>")
-                .append("<line class='chart-axis' x1='44' y1='180' x2='318' y2='180' stroke='#d5dde8' stroke-width='1'/>")
-                .append("<rect class='chart-bar chart-bar-before' x='92' y='").append(180 - beforeHeight)
-                .append("' width='64' height='").append(beforeHeight)
+                .append(renderAxisGridAndTicks(maxValue, axisLeft, axisRight, axisTop, axisBottom))
+                .append("<rect class='chart-bar chart-bar-before' x='").append(beforeX)
+                .append("' y='").append(axisBottom - beforeHeight)
+                .append("' width='").append(barWidth).append("' height='").append(beforeHeight)
                 .append("' rx='10' ry='10' fill='#9fb3c8'/>")
-                .append("<rect class='chart-bar chart-bar-after' x='204' y='").append(180 - afterHeight)
-                .append("' width='64' height='").append(afterHeight)
+                .append("<rect class='chart-bar chart-bar-after' x='").append(afterX)
+                .append("' y='").append(axisBottom - afterHeight)
+                .append("' width='").append(barWidth).append("' height='").append(afterHeight)
                 .append("' rx='10' ry='10' fill='").append(safeColor(fillColor)).append("'/>")
-                .append("<text class='chart-value' x='124' y='").append(Math.max(18, 170 - beforeHeight))
+                .append("<text class='chart-value' x='").append(beforeX + barWidth / 2)
+                .append("' y='").append(Math.max(18, axisBottom - 10 - beforeHeight))
                 .append("' text-anchor='middle'>").append(escape(beforeText)).append("</text>")
-                .append("<text class='chart-value' x='236' y='").append(Math.max(18, 170 - afterHeight))
+                .append("<text class='chart-value' x='").append(afterX + barWidth / 2)
+                .append("' y='").append(Math.max(18, axisBottom - 10 - afterHeight))
                 .append("' text-anchor='middle'>").append(escape(afterText)).append("</text>")
-                .append("<text class='chart-label' x='124' y='202' text-anchor='middle'>")
+                .append("<text class='chart-label' x='").append(beforeX + barWidth / 2)
+                .append("' y='").append(axisBottom + 22).append("' text-anchor='middle'>")
                 .append(escape(beforeLabel)).append("</text>")
-                .append("<text class='chart-label' x='236' y='202' text-anchor='middle'>")
+                .append("<text class='chart-label' x='").append(afterX + barWidth / 2)
+                .append("' y='").append(axisBottom + 22).append("' text-anchor='middle'>")
                 .append(escape(afterLabel)).append("</text>")
                 .append("</svg>")
                 .toString();
     }
 
-    private int barHeight(double value, double maxValue) {
+    private String renderAxisGridAndTicks(double maxValue, int axisLeft, int axisRight, int axisTop, int axisBottom) {
+        double[] tickValues = {maxValue, maxValue / 2d, 0d};
+        StringBuilder builder = new StringBuilder();
+        for (double tickValue : tickValues) {
+            int y = tickY(tickValue, maxValue, axisTop, axisBottom);
+            builder.append("<line class='chart-grid-line' x1='").append(axisLeft)
+                    .append("' y1='").append(y)
+                    .append("' x2='").append(axisRight)
+                    .append("' y2='").append(y)
+                    .append("' stroke='#edf2f7' stroke-width='1'/>")
+                    .append("<line class='chart-tick' x1='").append(axisLeft - 4)
+                    .append("' y1='").append(y)
+                    .append("' x2='").append(axisLeft)
+                    .append("' y2='").append(y)
+                    .append("' stroke='#a9b4c2' stroke-width='1'/>")
+                    .append("<text class='chart-tick-label' x='").append(axisLeft - 8)
+                    .append("' y='").append(y + 4)
+                    .append("' text-anchor='end'>")
+                    .append(formatTickLabel(tickValue))
+                    .append("</text>");
+        }
+        builder.append("<line class='chart-y-axis' x1='").append(axisLeft)
+                .append("' y1='").append(axisTop)
+                .append("' x2='").append(axisLeft)
+                .append("' y2='").append(axisBottom)
+                .append("' stroke='#9aa6b2' stroke-width='1'/>")
+                .append("<line class='chart-x-axis' x1='").append(axisLeft)
+                .append("' y1='").append(axisBottom)
+                .append("' x2='").append(axisRight)
+                .append("' y2='").append(axisBottom)
+                .append("' stroke='#9aa6b2' stroke-width='1'/>");
+        return builder.toString();
+    }
+
+    private int tickY(double value, double maxValue, int axisTop, int axisBottom) {
+        if (maxValue <= 0d) {
+            return axisBottom;
+        }
+        double ratio = Math.max(0d, Math.min(1d, value / maxValue));
+        return axisBottom - (int) Math.round(ratio * (axisBottom - axisTop));
+    }
+
+    private String formatTickLabel(double value) {
+        return String.format(Locale.ROOT, "%.0f", Math.max(0d, value));
+    }
+
+    private int barHeight(double value, double maxValue, int plotHeight) {
         if (value <= 0d || maxValue <= 0d) {
             return 0;
         }
         double ratio = Math.max(0d, Math.min(1d, value / maxValue));
-        return Math.max(18, (int) Math.round(ratio * 130d));
+        return (int) Math.round(ratio * plotHeight);
     }
 
     private String safeCssClass(String value) {

+ 6 - 0
abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html

@@ -165,6 +165,12 @@
             font-size: 12px;
         }
 
+        .chart-tick-label {
+            fill: #68768a;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-size: 11px;
+        }
+
         .detail-text {
             margin: 8px 0;
             color: #3d4a5d;

+ 29 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilderTest.java

@@ -0,0 +1,29 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AchievementExamSprintReportSvgChartBuilderTest {
+
+    @Test
+    void comparisonBarChartKeepsTinyPositiveBarsProportionalToAxisScale() {
+        AchievementExamSprintReportSvgChartBuilder builder = new AchievementExamSprintReportSvgChartBuilder();
+
+        String svg = builder.comparisonBarChart(
+                "vocabulary-growth-chart",
+                "词汇量对比",
+                "训练前",
+                1,
+                "1 词",
+                "训练后",
+                1000,
+                "1000 词",
+                "#ff7d00");
+
+        assertThat(svg)
+                .contains("<rect class='chart-bar chart-bar-before' x='118' y='180' width='58' height='0' rx='10' ry='10' fill='#9fb3c8'/>")
+                .contains("<rect class='chart-bar chart-bar-after' x='222' y='36' width='58' height='144' rx='10' ry='10' fill='#ff7d00'/>")
+                .doesNotContain("<rect class='chart-bar chart-bar-before' x='118' y='162' width='58' height='18'");
+    }
+}

+ 27 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java

@@ -36,6 +36,18 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .contains("模块三:实考生词命中状况")
                 .contains("class='achievement-bar-chart vocabulary-growth-chart'")
                 .contains("class='achievement-bar-chart paper-known-words-chart'")
+                .contains("chart-y-axis")
+                .contains("chart-x-axis")
+                .contains("chart-grid-line")
+                .contains("chart-tick-label")
+                .contains(".chart-tick-label {")
+                .contains("font-size: 11px;")
+                .contains("fill: #68768a;")
+                .contains(">0</text>")
+                .contains(">2347</text>")
+                .contains(">654</text>")
+                .contains("<rect class='chart-bar chart-bar-after' x='222' y='36' width='58' height='144' rx='10' ry='10' fill='#ff7d00'/>")
+                .contains("<rect class='chart-bar chart-bar-after' x='222' y='36' width='58' height='144' rx='10' ry='10' fill='#3f8cff'/>")
                 .contains("训练前词汇量:<span class=\"highlight\">2328 词</span>")
                 .contains("训练后词汇量:<span class=\"highlight\">2347 词</span>")
                 .contains("本次提升:<span class=\"highlight\">+19 词</span>")
@@ -47,6 +59,11 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .doesNotContain("cdn.jsdelivr.net")
                 .doesNotContain("echarts")
                 .doesNotContain("<script");
+
+        assertThat(countOccurrences(html, "class='chart-grid-line'")).isEqualTo(6);
+        assertThat(countOccurrences(html, "class='chart-tick-label'")).isEqualTo(6);
+        assertThat(countOccurrences(html, "class='chart-y-axis'")).isEqualTo(2);
+        assertThat(countOccurrences(html, "class='chart-x-axis'")).isEqualTo(2);
     }
 
     @Test
@@ -169,4 +186,14 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 }
                 """);
     }
+
+    private int countOccurrences(String value, String substring) {
+        int count = 0;
+        int index = 0;
+        while ((index = value.indexOf(substring, index)) >= 0) {
+            count++;
+            index += substring.length();
+        }
+        return count;
+    }
 }