|
|
@@ -39,24 +39,14 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
|
|
|
public String render(JsonNode payload, Instant generatedAt) {
|
|
|
try {
|
|
|
OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
|
|
|
- OutlookExamSprintReportPayload.ReportMetadata metadata = reportPayload.reportMetadata();
|
|
|
return loadTemplate()
|
|
|
- .replace("{{reportVersionLabel}}", escape(metadata.reportVersionLabel()))
|
|
|
- .replace("{{learnerName}}", escape(metadata.learnerName()))
|
|
|
- .replace("{{targetExamName}}", escape(metadata.targetExamName()))
|
|
|
- .replace("{{sprintPeriodLabel}}", escape(metadata.sprintPeriodLabel()))
|
|
|
- .replace("{{authorName}}", escape(metadata.authorName()))
|
|
|
- .replace("{{generatedAt}}", escape(DATE_TIME_FORMATTER.format(generatedAt)))
|
|
|
- .replace("{{summary}}", escape(reportPayload.readinessOverview().summary()))
|
|
|
- .replace("{{currentStage}}", escape(reportPayload.readinessOverview().currentStage()))
|
|
|
- .replace("{{keyInsight}}", escape(reportPayload.readinessOverview().keyInsight()))
|
|
|
- .replace("{{readinessScore}}", String.valueOf(reportPayload.readinessOverview().readinessScore()))
|
|
|
- .replace("{{syllabusMasteryProfile}}", renderSyllabusMasteryProfile(reportPayload.syllabusMasteryProfile()))
|
|
|
- .replace("{{pastPaperVocabularyProfile}}", renderVocabularyProfile("真题试卷词汇掌握情况", reportPayload.pastPaperVocabularyProfile()))
|
|
|
- .replace("{{highFrequencyVocabularyProfile}}", renderVocabularyProfile("常考词汇掌握情况", reportPayload.highFrequencyVocabularyProfile()))
|
|
|
- .replace("{{vocabularyFrequencyBands}}", renderVocabularyFrequencyBands(reportPayload.vocabularyFrequencyBands()))
|
|
|
- .replace("{{sprintPlanOptions}}", renderSprintPlanOptions(reportPayload.sprintPlanOptions()))
|
|
|
- .replace("{{diagnosticCaseStudy}}", renderDiagnosticCaseStudy(reportPayload.diagnosticCaseStudy()));
|
|
|
+ .replace("{{reportIntroShell}}", renderReportIntroShell(reportPayload, generatedAt))
|
|
|
+ .replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart()))
|
|
|
+ .replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart()))
|
|
|
+ .replace("{{highFrequencyVocabularyChart}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularyChart()))
|
|
|
+ .replace("{{vocabularyFrequencyBandChart}}", renderVocabularyFrequencyBandChart(reportPayload.vocabularyFrequencyBandChart()))
|
|
|
+ .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.frequencyPlan()))
|
|
|
+ .replace("{{scoreImprovementCaseStudy}}", renderScoreImprovementCaseStudy(reportPayload.scoreImprovementCaseStudy()));
|
|
|
} catch (IOException exception) {
|
|
|
throw new UncheckedIOException("Failed to load outlook exam sprint report template", exception);
|
|
|
} catch (Exception exception) {
|
|
|
@@ -70,101 +60,259 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private String renderSyllabusMasteryProfile(OutlookExamSprintReportPayload.SyllabusMasteryProfile profile) {
|
|
|
- StringBuilder builder = new StringBuilder();
|
|
|
- builder.append("<div class='card-title'>考纲词汇掌握情况</div>")
|
|
|
- .append("<div class='card-body'>")
|
|
|
- .append("<p><strong>掌握度:</strong>").append(profile.masteryPercent()).append("%</p>")
|
|
|
- .append("<p><strong>诊断:</strong>").append(escape(profile.diagnosis())).append("</p>")
|
|
|
- .append("<p><strong>建议:</strong>").append(escape(profile.recommendation())).append("</p>")
|
|
|
- .append("<ul>");
|
|
|
- for (OutlookExamSprintReportPayload.DimensionScore score : profile.dimensionScores()) {
|
|
|
- builder.append("<li>")
|
|
|
- .append(escape(score.label()))
|
|
|
- .append(":")
|
|
|
- .append(score.score())
|
|
|
- .append("%</li>");
|
|
|
- }
|
|
|
- builder.append("</ul></div>");
|
|
|
- return builder.toString();
|
|
|
+ private String renderReportIntroShell(OutlookExamSprintReportPayload payload, Instant generatedAt) {
|
|
|
+ OutlookExamSprintReportPayload.ReportMetadata metadata = payload.reportMetadata();
|
|
|
+ OutlookExamSprintReportPayload.ReadinessOverview readinessOverview = payload.readinessOverview();
|
|
|
+
|
|
|
+ return new StringBuilder()
|
|
|
+ .append("<div class='report-intro-shell'>")
|
|
|
+ .append("<div class='report-intro-meta'>")
|
|
|
+ .append("<span>报告版本:").append(escape(metadata.reportVersionLabel())).append("</span>")
|
|
|
+ .append("<span>学生:").append(escape(metadata.learnerName())).append("</span>")
|
|
|
+ .append("<span>目标考试:").append(escape(metadata.targetExamName())).append("</span>")
|
|
|
+ .append("<span>冲刺周期:").append(escape(metadata.sprintPeriodLabel())).append("</span>")
|
|
|
+ .append("<span>作者:").append(escape(metadata.authorName())).append("</span>")
|
|
|
+ .append("<span>生成时间:").append(escape(DATE_TIME_FORMATTER.format(generatedAt))).append("</span>")
|
|
|
+ .append("</div>")
|
|
|
+ .append("<p class='report-intro-summary'>").append(escape(readinessOverview.summary())).append("</p>")
|
|
|
+ .append("<p class='report-intro-insight'>核心观察:").append(escape(readinessOverview.keyInsight())).append("</p>")
|
|
|
+ .append("<p class='report-intro-summary'>当前阶段:").append(escape(readinessOverview.currentStage())).append(" · 备考得分 ")
|
|
|
+ .append(readinessOverview.readinessScore()).append("</p>")
|
|
|
+ .append("</div>")
|
|
|
+ .toString();
|
|
|
}
|
|
|
|
|
|
- private String renderVocabularyProfile(String title, OutlookExamSprintReportPayload.VocabularyProfile profile) {
|
|
|
- StringBuilder builder = new StringBuilder();
|
|
|
- builder.append("<div class='card-title'>").append(title).append("</div>")
|
|
|
- .append("<div class='card-body'>")
|
|
|
- .append("<p><strong>已掌握:</strong>").append(profile.masteredWordCount()).append(" / ")
|
|
|
- .append(profile.totalWordCount()).append(" 词(").append(profile.masteryPercent()).append("%)</p>")
|
|
|
- .append("<p><strong>诊断:</strong>").append(escape(profile.diagnosis())).append("</p>")
|
|
|
- .append("<p><strong>建议:</strong>").append(escape(profile.recommendation())).append("</p>")
|
|
|
- .append(renderSampleWords(profile.sampleWords()))
|
|
|
- .append("</div>");
|
|
|
- return builder.toString();
|
|
|
+ private String renderSyllabusMasteryChart(OutlookExamSprintReportPayload.SyllabusMasteryChart chart) {
|
|
|
+ int masteredArcPercent = Math.max(0, Math.min(100, chart.masteryPercent()));
|
|
|
+ int unmasteredArcPercent = 100 - masteredArcPercent;
|
|
|
+
|
|
|
+ return new StringBuilder()
|
|
|
+ .append("<div class='card-title'>考纲词汇掌握情况</div>")
|
|
|
+ .append("<div class='chart-shell'>")
|
|
|
+ .append("<svg class='syllabus-donut-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 220' role='img' aria-label='考纲词汇掌握情况'>")
|
|
|
+ .append("<circle class='chart-track' cx='110' cy='110' r='76'></circle>")
|
|
|
+ .append("<path class='donut-mastered-arc' d='")
|
|
|
+ .append(describeArc(110, 110, 76, -90, -90 + (masteredArcPercent * 3.6)))
|
|
|
+ .append("' stroke='#448aff' stroke-width='18' fill='none' stroke-linecap='round'/>")
|
|
|
+ .append("<path class='donut-unmastered-arc' d='")
|
|
|
+ .append(describeArc(110, 110, 76, -90 + (masteredArcPercent * 3.6), 270))
|
|
|
+ .append("' stroke='#e8eef7' stroke-width='18' fill='none' stroke-linecap='round'/>")
|
|
|
+ .append("<text class='chart-percent' x='110' y='106' text-anchor='middle'>")
|
|
|
+ .append(chart.masteryPercent()).append("%</text>")
|
|
|
+ .append("<text class='chart-caption' x='110' y='131' text-anchor='middle'>")
|
|
|
+ .append(escape(chart.summaryLabel())).append("</text>")
|
|
|
+ .append("</svg>")
|
|
|
+ .append("</div>")
|
|
|
+ .append("<div class='data-text'>")
|
|
|
+ .append("已掌握 ").append(chart.masteredWordCount()).append(" / ").append(chart.totalWordCount())
|
|
|
+ .append(" 词,未掌握 ").append(chart.unmasteredWordCount()).append(" 词。</div>")
|
|
|
+ .append("<p class='chart-note'>").append(escape(chart.recommendation())).append("</p>")
|
|
|
+ .toString();
|
|
|
}
|
|
|
|
|
|
- private String renderSampleWords(List<String> sampleWords) {
|
|
|
- if (sampleWords == null || sampleWords.isEmpty()) {
|
|
|
- return "";
|
|
|
- }
|
|
|
+ private String renderPastPaperVocabularyChart(OutlookExamSprintReportPayload.PastPaperVocabularyChart chart) {
|
|
|
+ int totalHeight = barHeight(chart.totalWordCount(), Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()));
|
|
|
+ int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()));
|
|
|
|
|
|
- StringBuilder builder = new StringBuilder("<div class='chip-group'>");
|
|
|
- for (String sampleWord : sampleWords) {
|
|
|
- builder.append("<span class='chip'>").append(escape(sampleWord)).append("</span>");
|
|
|
- }
|
|
|
- builder.append("</div>");
|
|
|
- return builder.toString();
|
|
|
+ return new StringBuilder()
|
|
|
+ .append("<div class='card-title'>真题试卷词汇掌握情况</div>")
|
|
|
+ .append("<div class='chart-shell'>")
|
|
|
+ .append("<svg class='past-paper-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='真题试卷词汇掌握情况'>")
|
|
|
+ .append(renderChartAxes(320, 220))
|
|
|
+ .append("<rect class='chart-column total-column' x='84' y='").append(180 - totalHeight)
|
|
|
+ .append("' width='56' height='").append(totalHeight).append("' rx='8' ry='8' fill='#448aff'/>")
|
|
|
+ .append("<rect class='chart-column unknown-column' x='180' y='").append(180 - unknownHeight)
|
|
|
+ .append("' width='56' height='").append(unknownHeight).append("' rx='8' ry='8' fill='#ff9800'/>")
|
|
|
+ .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>总词量</text>")
|
|
|
+ .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>未知词</text>")
|
|
|
+ .append("</svg>")
|
|
|
+ .append("</div>")
|
|
|
+ .append("<div class='data-text'>总词量 ").append(chart.totalWordCount())
|
|
|
+ .append(" 词,冲刺前未知 ").append(chart.unknownWordCountBeforeSprint())
|
|
|
+ .append(" 词,冲刺后未知 ").append(chart.unknownWordCountAfterSprint()).append(" 词。</div>")
|
|
|
+ .append("<p class='chart-note'>").append(escape(chart.projectedScoreGainLabel())).append(" · ")
|
|
|
+ .append(escape(chart.recommendation())).append("</p>")
|
|
|
+ .toString();
|
|
|
}
|
|
|
|
|
|
- private String renderVocabularyFrequencyBands(List<OutlookExamSprintReportPayload.VocabularyFrequencyBand> bands) {
|
|
|
- StringBuilder builder = new StringBuilder("<div class='frequency-list'>");
|
|
|
- for (OutlookExamSprintReportPayload.VocabularyFrequencyBand band : bands) {
|
|
|
- builder.append("<div class='frequency-item'><strong>")
|
|
|
- .append(escape(band.bandLabel()))
|
|
|
- .append("</strong>:当前 ")
|
|
|
- .append(band.masteryPercent())
|
|
|
- .append("%,目标 ")
|
|
|
- .append(band.targetPercent())
|
|
|
- .append("%</div>");
|
|
|
+ private String renderHighFrequencyVocabularyChart(OutlookExamSprintReportPayload.HighFrequencyVocabularyChart chart) {
|
|
|
+ int basicHeight = barHeight(chart.basicCorePercent(), 100);
|
|
|
+ int highScoreHeight = barHeight(chart.highScorePercent(), 100);
|
|
|
+
|
|
|
+ return new StringBuilder()
|
|
|
+ .append("<div class='card-title'>常考词汇掌握情况</div>")
|
|
|
+ .append("<div class='chart-shell'>")
|
|
|
+ .append("<svg class='high-frequency-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='常考词汇掌握情况'>")
|
|
|
+ .append(renderChartAxes(320, 220))
|
|
|
+ .append("<rect class='chart-column basic-core-column' x='84' y='").append(180 - basicHeight)
|
|
|
+ .append("' width='56' height='").append(basicHeight).append("' rx='8' ry='8' fill='#42a5f5'/>")
|
|
|
+ .append("<rect class='chart-column high-score-column' x='180' y='").append(180 - highScoreHeight)
|
|
|
+ .append("' width='56' height='").append(highScoreHeight).append("' rx='8' ry='8' fill='#66bb6a'/>")
|
|
|
+ .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>基础核心</text>")
|
|
|
+ .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>拉分词</text>")
|
|
|
+ .append("</svg>")
|
|
|
+ .append("</div>")
|
|
|
+ .append("<div class='data-text'>基础核心 ").append(chart.basicCorePercent()).append("% ,高分词 ")
|
|
|
+ .append(chart.highScorePercent()).append("%。</div>")
|
|
|
+ .append("<p class='chart-note'>").append(escape(chart.highlightLabel())).append("</p>")
|
|
|
+ .toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String renderVocabularyFrequencyBandChart(OutlookExamSprintReportPayload.VocabularyFrequencyBandChart chart) {
|
|
|
+ List<OutlookExamSprintReportPayload.VocabularyFrequencyBar> bars = chart.bars();
|
|
|
+ int highest = bars.stream().mapToInt(bar -> (int) Math.ceil(bar.currentValue())).max().orElse(1);
|
|
|
+
|
|
|
+ StringBuilder builder = new StringBuilder();
|
|
|
+ builder.append("<svg class='frequency-band-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='词频区间掌握度'>");
|
|
|
+ builder.append(renderChartAxes(360, 220));
|
|
|
+
|
|
|
+ int[] xPositions = {70, 160, 250};
|
|
|
+ String[] columnClasses = {"high-band-column", "mid-band-column", "low-band-column"};
|
|
|
+ for (int index = 0; index < Math.min(3, bars.size()); index++) {
|
|
|
+ OutlookExamSprintReportPayload.VocabularyFrequencyBar bar = bars.get(index);
|
|
|
+ int height = barHeight((int) Math.round(bar.currentValue()), highest);
|
|
|
+ int x = xPositions[index];
|
|
|
+ builder.append("<rect class='chart-column ").append(columnClasses[index]).append("' x='")
|
|
|
+ .append(x).append("' y='").append(180 - height).append("' width='54' height='")
|
|
|
+ .append(height).append("' rx='8' ry='8' fill='").append(escape(bar.themeColor())).append("'/>")
|
|
|
+ .append("<text class='donut-label-text' x='").append(x + 27).append("' y='198' text-anchor='middle'>")
|
|
|
+ .append(escape(bar.bandLabel())).append("</text>")
|
|
|
+ .append("<text class='chart-caption' x='").append(x + 27).append("' y='20' text-anchor='middle'>")
|
|
|
+ .append(escape(bar.priorityLabel())).append("</text>");
|
|
|
}
|
|
|
- builder.append("</div>");
|
|
|
+
|
|
|
+ builder.append("</svg>");
|
|
|
return builder.toString();
|
|
|
}
|
|
|
|
|
|
- private String renderSprintPlanOptions(List<OutlookExamSprintReportPayload.SprintPlanOption> options) {
|
|
|
+ private String renderStudySuggestionSection(OutlookExamSprintReportPayload.FrequencyPlan frequencyPlan) {
|
|
|
StringBuilder builder = new StringBuilder();
|
|
|
- for (OutlookExamSprintReportPayload.SprintPlanOption option : options) {
|
|
|
- builder.append("<div class='plan-card'>")
|
|
|
- .append("<div class='plan-title'>")
|
|
|
- .append(escape(option.planName()));
|
|
|
- if (option.tagLabel() != null && !option.tagLabel().isBlank()) {
|
|
|
- builder.append(" <span class='tag'>").append(escape(option.tagLabel())).append("</span>");
|
|
|
+ builder.append("<div class='study-suggestion-shell'>")
|
|
|
+ .append("<div class='study-suggestion-intro'>")
|
|
|
+ .append("<h3>").append(escape(frequencyPlan.recommendationTitle())).append("</h3>")
|
|
|
+ .append("<p>").append(escape(frequencyPlan.recommendationSummary())).append("</p>")
|
|
|
+ .append("</div>")
|
|
|
+ .append("<div class='study-frequency-grid'>");
|
|
|
+
|
|
|
+ for (OutlookExamSprintReportPayload.FrequencyPlanCard card : frequencyPlan.cards()) {
|
|
|
+ builder.append("<div class='study-frequency-card")
|
|
|
+ .append(card.recommended() ? " active" : "")
|
|
|
+ .append("'>")
|
|
|
+ .append("<div class='study-frequency-header'>")
|
|
|
+ .append(escape(card.cadencePerWeek() + "套/周"));
|
|
|
+ if (card.emphasisIcon() != null && !card.emphasisIcon().isBlank()) {
|
|
|
+ builder.append("<span class='crown'>").append(escape(card.emphasisIcon())).append("</span>");
|
|
|
}
|
|
|
builder.append("</div>")
|
|
|
- .append("<div class='plan-cadence'>").append(escape(option.cadenceLabel())).append("</div>")
|
|
|
- .append("<p><strong>重点:</strong>").append(escape(option.focus())).append("</p>")
|
|
|
- .append("<ul>");
|
|
|
- for (String actionItem : option.actionItems()) {
|
|
|
- builder.append("<li>").append(escape(actionItem)).append("</li>");
|
|
|
+ .append("<div class='study-frequency-progress'><span class='study-frequency-progress-fill' style='width:")
|
|
|
+ .append(card.winRatePercent()).append("%'></span></div>")
|
|
|
+ .append("<div class='study-frequency-data'>")
|
|
|
+ .append("<span class='win-rate'>").append(escape(card.scoreGainLabel())).append("</span> · ")
|
|
|
+ .append(card.winRatePercent()).append("%")
|
|
|
+ .append("</div>");
|
|
|
+
|
|
|
+ if (card.badgeLabel() != null && !card.badgeLabel().isBlank()) {
|
|
|
+ builder.append("<div class='badge'>").append(escape(card.badgeLabel())).append("</div>");
|
|
|
}
|
|
|
- builder.append("</ul>")
|
|
|
- .append("<p><strong>预期:</strong>").append(escape(option.expectedOutcome())).append("</p>")
|
|
|
+
|
|
|
+ builder.append("</div>");
|
|
|
+ }
|
|
|
+
|
|
|
+ builder.append("</div>")
|
|
|
+ .append("<div class='study-strategy-note'><span class='study-strategy-label'>")
|
|
|
+ .append(escape(frequencyPlan.recommendationTitle()))
|
|
|
+ .append("</span>")
|
|
|
+ .append(escape(frequencyPlan.recommendationSummary()))
|
|
|
+ .append("</div>")
|
|
|
+ .append("<div class='study-stage-box'>");
|
|
|
+
|
|
|
+ for (OutlookExamSprintReportPayload.PhaseSuggestion suggestion : frequencyPlan.phaseSuggestions()) {
|
|
|
+ builder.append("<div class='study-stage-item'>")
|
|
|
+ .append("<h4>").append(escape(suggestion.title())).append("</h4>")
|
|
|
+ .append("<p>").append(escape(suggestion.description())).append("</p>")
|
|
|
.append("</div>");
|
|
|
}
|
|
|
+
|
|
|
+ builder.append("</div></div>");
|
|
|
return builder.toString();
|
|
|
}
|
|
|
|
|
|
- private String renderDiagnosticCaseStudy(OutlookExamSprintReportPayload.DiagnosticCaseStudy caseStudy) {
|
|
|
+ private String renderScoreImprovementCaseStudy(OutlookExamSprintReportPayload.ScoreImprovementCaseStudy caseStudy) {
|
|
|
return new StringBuilder()
|
|
|
- .append("<div class='case-card'>")
|
|
|
- .append("<h3>").append(escape(caseStudy.title())).append("</h3>")
|
|
|
- .append("<p><strong>背景:</strong>").append(escape(caseStudy.context())).append("</p>")
|
|
|
- .append("<p><strong>诊断:</strong>").append(escape(caseStudy.diagnosis())).append("</p>")
|
|
|
- .append("<p><strong>策略:</strong>").append(escape(caseStudy.strategy())).append("</p>")
|
|
|
- .append("<p><strong>启示:</strong>").append(escape(caseStudy.keyTakeaway())).append("</p>")
|
|
|
+ .append("<div class='case-study-shell'>")
|
|
|
+ .append("<div class='case-study-visual'>")
|
|
|
+ .append("<svg class='case-study-visual-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260' role='img' aria-label='上届学员提分案例图示'>")
|
|
|
+ .append("<circle cx='130' cy='130' r='92' fill='#fff3e5' stroke='#f3d7bb' stroke-width='2'/>")
|
|
|
+ .append("<path d='M52 176 L104 124 L136 146 L204 82' fill='none' stroke='#ff7d00' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/>")
|
|
|
+ .append("<circle cx='52' cy='176' r='8' fill='#ff7d00'/>")
|
|
|
+ .append("<circle cx='104' cy='124' r='8' fill='#ff7d00'/>")
|
|
|
+ .append("<circle cx='136' cy='146' r='8' fill='#ff7d00'/>")
|
|
|
+ .append("<circle cx='204' cy='82' r='8' fill='#ff7d00'/>")
|
|
|
+ .append("<text x='130' y='214' text-anchor='middle' fill='#8a5d36' font-size='14'>")
|
|
|
+ .append(escape(caseStudy.baselineScoreLabel())).append(" → ").append(caseStudy.finalScore()).append("分</text>")
|
|
|
+ .append("</svg>")
|
|
|
+ .append("<div class='case-hit-rate-badge'><span class='case-hit-rate-value'>")
|
|
|
+ .append(caseStudy.hitRatePercent()).append("%</span><span class='case-hit-rate-label'>命中率</span></div>")
|
|
|
+ .append("</div>")
|
|
|
+ .append("<div class='case-info'>")
|
|
|
+ .append("<h3>").append(escape(caseStudy.headline())).append("</h3>")
|
|
|
+ .append(caseInfoSection("背景", caseStudy.studyPeriodLabel()))
|
|
|
+ .append(caseInfoSection("学生", caseStudy.learnerName()))
|
|
|
+ .append(caseInfoSection("记忆词汇", caseStudy.memorizedWordCount() + "词"))
|
|
|
+ .append(caseInfoSection("高考命中", caseStudy.examHitWordCount() + "词"))
|
|
|
+ .append(caseInfoSection("结果", caseStudy.baselineScoreLabel() + " · " + caseStudy.finalScore() + "分 · 提升分数:+" + caseStudy.scoreGain() + "分"))
|
|
|
.append("</div>")
|
|
|
+ .append("</div>")
|
|
|
+ .toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private StringBuilder caseInfoSection(String label, String value) {
|
|
|
+ return new StringBuilder()
|
|
|
+ .append("<div class='case-info-section'>")
|
|
|
+ .append("<div class='case-info-group'><span class='case-info-label'>")
|
|
|
+ .append(escape(label)).append(":</span><span class='case-info-value'>")
|
|
|
+ .append(escape(value)).append("</span></div>")
|
|
|
+ .append("</div>");
|
|
|
+ }
|
|
|
+
|
|
|
+ private String renderChartAxes(int width, int height) {
|
|
|
+ return new StringBuilder()
|
|
|
+ .append("<line class='chart-axis' x1='34' y1='18' x2='34' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
|
|
|
+ .append("<line class='chart-axis' x1='34' y1='180' x2='").append(width - 24).append("' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
|
|
|
.toString();
|
|
|
}
|
|
|
|
|
|
+ private int barHeight(int value, int maxValue) {
|
|
|
+ if (maxValue <= 0) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ double ratio = Math.max(0d, Math.min(1d, value / (double) maxValue));
|
|
|
+ return Math.max(16, (int) Math.round(ratio * 130));
|
|
|
+ }
|
|
|
+
|
|
|
+ private String describeArc(double centerX, double centerY, double radius, double startAngle, double endAngle) {
|
|
|
+ double startRadians = Math.toRadians(startAngle);
|
|
|
+ double endRadians = Math.toRadians(endAngle);
|
|
|
+ double startX = centerX + (radius * Math.cos(startRadians));
|
|
|
+ double startY = centerY + (radius * Math.sin(startRadians));
|
|
|
+ double endX = centerX + (radius * Math.cos(endRadians));
|
|
|
+ double endY = centerY + (radius * Math.sin(endRadians));
|
|
|
+ double largeArcFlag = Math.abs(endAngle - startAngle) <= 180 ? 0 : 1;
|
|
|
+ return new StringBuilder()
|
|
|
+ .append("M ").append(formatDouble(startX)).append(' ')
|
|
|
+ .append(formatDouble(startY)).append(' ')
|
|
|
+ .append("A ").append(formatDouble(radius)).append(' ')
|
|
|
+ .append(formatDouble(radius)).append(" 0 ")
|
|
|
+ .append((int) largeArcFlag).append(" 1 ")
|
|
|
+ .append(formatDouble(endX)).append(' ')
|
|
|
+ .append(formatDouble(endY))
|
|
|
+ .toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String formatDouble(double value) {
|
|
|
+ return String.format(java.util.Locale.ROOT, "%.2f", value);
|
|
|
+ }
|
|
|
+
|
|
|
private String escape(String value) {
|
|
|
if (value == null) {
|
|
|
return "";
|