|
|
@@ -500,6 +500,155 @@ $buildDualChartsHtml = static function (array $curDaily, array $prevDaily): stri
|
|
|
|
|
|
$chartSvg = $buildDualChartsHtml($curDaily, $prevDaily);
|
|
|
|
|
|
+// ============================================================
|
|
|
+// 1 月 6 日起每日学案量点状图
|
|
|
+// ============================================================
|
|
|
+$cumulativeStart = \Carbon\Carbon::create(2026, 1, 6, 0, 0, 0, $tz);
|
|
|
+$cumulativeEnd = $endCurrent->copy();
|
|
|
+
|
|
|
+$dailyRows = $db::table('papers')
|
|
|
+ ->whereNotNull('teacher_id')
|
|
|
+ ->where('teacher_id', '!=', '')
|
|
|
+ ->where('created_at', '>=', $cumulativeStart)
|
|
|
+ ->where('created_at', '<', $cumulativeEnd)
|
|
|
+ ->selectRaw("DATE(CONVERT_TZ(created_at, '+00:00', '+08:00')) AS d, COUNT(*) AS c")
|
|
|
+ ->groupByRaw("DATE(CONVERT_TZ(created_at, '+00:00', '+08:00'))")
|
|
|
+ ->orderBy('d')
|
|
|
+ ->get();
|
|
|
+
|
|
|
+$dailyMap = [];
|
|
|
+foreach ($dailyRows as $row) {
|
|
|
+ $dailyMap[$row->d] = (int) $row->c;
|
|
|
+}
|
|
|
+
|
|
|
+$period = \Carbon\CarbonPeriod::create($cumulativeStart->toDateString(), $cumulativeEnd->copy()->subDay()->toDateString());
|
|
|
+$dotLabels = [];
|
|
|
+$dotValues = [];
|
|
|
+foreach ($period as $day) {
|
|
|
+ $key = $day->format('Y-m-d');
|
|
|
+ $dotLabels[] = $day->format('m/d');
|
|
|
+ $dotValues[] = $dailyMap[$key] ?? 0;
|
|
|
+}
|
|
|
+$totalDays = count($dotValues);
|
|
|
+$cumulativeTotal = array_sum($dotValues);
|
|
|
+$cumulativeAvg = $totalDays > 0 ? round($cumulativeTotal / $totalDays, 1) : 0;
|
|
|
+$cumulativeMax = $totalDays > 0 ? max($dotValues) : 0;
|
|
|
+$cumulativeMaxDate = '';
|
|
|
+foreach ($dotValues as $di => $dv) {
|
|
|
+ if ($dv === $cumulativeMax && $cumulativeMax === $cumulativeMax) {
|
|
|
+ $cumulativeMaxDate = $dotLabels[$di];
|
|
|
+ break;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+$buildDotChartHtml = static function (array $labels, array $values, int $totalDays, float $avg): string {
|
|
|
+ $maxY = max(1, ...$values);
|
|
|
+ $W = min(1200, max(500, $totalDays * 8));
|
|
|
+ $H = 320;
|
|
|
+ $padL = 48;
|
|
|
+ $padR = 24;
|
|
|
+ $padT = 42;
|
|
|
+ $padB = 72;
|
|
|
+ $gw = $W - $padL - $padR;
|
|
|
+ $gh = $H - $padT - $padB;
|
|
|
+ $n = max(1, count($labels));
|
|
|
+
|
|
|
+ $xAt = static function (int $i) use ($padL, $gw, $n): float {
|
|
|
+ return $padL + ($n <= 1 ? $gw / 2 : $gw * $i / ($n - 1));
|
|
|
+ };
|
|
|
+ $yAt = static function (int $v) use ($padT, $gh, $maxY): float {
|
|
|
+ return $padT + $gh - ($v / $maxY) * $gh;
|
|
|
+ };
|
|
|
+
|
|
|
+ $svg = sprintf(
|
|
|
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" height="auto" style="max-width:100%%;">',
|
|
|
+ $W, $H
|
|
|
+ );
|
|
|
+ $svg .= sprintf('<rect x="0" y="0" width="%d" height="%d" fill="#fafafa"/>', $W, $H);
|
|
|
+
|
|
|
+ // Y grid
|
|
|
+ $yTickSteps = 5;
|
|
|
+ for ($k = 0; $k <= $yTickSteps; $k++) {
|
|
|
+ $v = (int) round($maxY * $k / $yTickSteps);
|
|
|
+ $y = $yAt($v);
|
|
|
+ $svg .= sprintf(
|
|
|
+ '<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="#e5e7eb" stroke-width="1"/>',
|
|
|
+ $padL, $y, $padL + $gw, $y
|
|
|
+ );
|
|
|
+ $svg .= sprintf(
|
|
|
+ '<text x="%d" y="%.1f" font-size="8" fill="#6b7280" font-family="sun-exta,sans-serif">%d</text>',
|
|
|
+ 4, $y + 3, $v
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // X axis line
|
|
|
+ $xAxisY = $padT + $gh;
|
|
|
+ $svg .= sprintf(
|
|
|
+ '<line x1="%d" y1="%.1f" x2="%d" y1="%.1f" stroke="#9ca3af" stroke-width="1"/>',
|
|
|
+ $padL, $xAxisY, $padL + $gw, $xAxisY
|
|
|
+ );
|
|
|
+
|
|
|
+ // Avg line
|
|
|
+ $avgY = $yAt((int) round($avg));
|
|
|
+ $svg .= sprintf(
|
|
|
+ '<line x1="%d" y1="%.1f" x2="%d" y1="%.1f" stroke="#f59e0b" stroke-width="1.2" stroke-dasharray="5,3"/>',
|
|
|
+ $padL, $avgY, $padL + $gw, $avgY
|
|
|
+ );
|
|
|
+ $svg .= sprintf(
|
|
|
+ '<text x="%d" y="%.1f" font-size="8" fill="#d97706" font-family="sun-exta,sans-serif">均值 %.0f</text>',
|
|
|
+ $padL + $gw + 2, $avgY + 3, $avg
|
|
|
+ );
|
|
|
+
|
|
|
+ // Dots + polyline
|
|
|
+ $pts = [];
|
|
|
+ $dots = '';
|
|
|
+ $xLabelStep = max(1, (int) ceil($n / 16));
|
|
|
+ $xLabels = '';
|
|
|
+ for ($i = 0; $i < $n; $i++) {
|
|
|
+ $x = $xAt($i);
|
|
|
+ $y = $yAt((int) $values[$i]);
|
|
|
+ $pts[] = round($x, 1) . ',' . round($y, 1);
|
|
|
+
|
|
|
+ $color = $values[$i] >= $avg ? '#2563eb' : '#93c5fd';
|
|
|
+ $radius = $values[$i] >= $avg ? 3.5 : 2.8;
|
|
|
+ $dots .= sprintf(
|
|
|
+ '<circle cx="%.1f" cy="%.1f" r="%.1f" fill="%s" opacity="0.85"/>',
|
|
|
+ $x, $y, $radius, $color
|
|
|
+ );
|
|
|
+
|
|
|
+ if ($i % $xLabelStep === 0 || $i === $n - 1) {
|
|
|
+ $xLabels .= sprintf(
|
|
|
+ '<text x="%.1f" y="%d" text-anchor="middle" font-size="7.5" fill="#6b7280" font-family="sun-exta,sans-serif" transform="rotate(-45,%.1f,%d)">%s</text>',
|
|
|
+ $x, $xAxisY + 14, $x, $xAxisY + 14,
|
|
|
+ htmlspecialchars($labels[$i], ENT_QUOTES, 'UTF-8')
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Polyline connecting dots
|
|
|
+ $svg .= sprintf(
|
|
|
+ '<polyline fill="none" stroke="#93c5fd" stroke-width="1" points="%s" />',
|
|
|
+ implode(' ', $pts)
|
|
|
+ );
|
|
|
+ $svg .= $dots;
|
|
|
+ $svg .= $xLabels;
|
|
|
+
|
|
|
+ // Legend
|
|
|
+ $svg .= '<g font-family="sun-exta,sans-serif" font-size="9">';
|
|
|
+ $svg .= sprintf('<circle cx="%d" cy="%d" r="3.5" fill="#2563eb" opacity="0.85"/>', $padL + 4, $H - 20);
|
|
|
+ $svg .= sprintf('<text x="%d" y="%d" fill="#111">≥ 均值</text>', $padL + 12, $H - 16);
|
|
|
+ $svg .= sprintf('<circle cx="%d" cy="%d" r="2.8" fill="#93c5fd" opacity="0.85"/>', $padL + 64, $H - 20);
|
|
|
+ $svg .= sprintf('<text x="%d" y="%d" fill="#111">< 均值</text>', $padL + 72, $H - 16);
|
|
|
+ $svg .= sprintf('<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#f59e0b" stroke-width="1.2" stroke-dasharray="5,3"/>', $padL + 124, $H - 20, $padL + 140, $H - 20);
|
|
|
+ $svg .= sprintf('<text x="%d" y="%d" fill="#d97706">日均线</text>', $padL + 144, $H - 16);
|
|
|
+ $svg .= '</g>';
|
|
|
+
|
|
|
+ $svg .= '</svg>';
|
|
|
+ return $svg;
|
|
|
+};
|
|
|
+
|
|
|
+$dotChartSvg = $totalDays > 0 ? $buildDotChartHtml($dotLabels, $dotValues, $totalDays, $cumulativeAvg) : '';
|
|
|
+
|
|
|
echo sprintf("## 老师组卷与学情分析(近%d个自然日)\n\n", $reportPeriodDays);
|
|
|
echo "> 生成 {$generatedAt} · {$tz} · 本 {$windowCur} · 上 {$windowPrev}\n";
|
|
|
echo sprintf("> 口径:自然日边界(config timezone);今日段为当日 0:00—生成时刻;上周期为紧邻的前 **%d** 个完整自然日。\n\n", $reportPeriodDays);
|
|
|
@@ -516,6 +665,14 @@ echo '<div class="weekly-chart">';
|
|
|
echo $chartSvg;
|
|
|
echo "</div>\n\n";
|
|
|
|
|
|
+if ($dotChartSvg !== '') {
|
|
|
+ echo sprintf("### 1月6日至今每日学案量(共 %d 天,累计 %d 套)\n\n", $totalDays, $cumulativeTotal);
|
|
|
+ echo sprintf("> 日均 **%.1f** 套 · 峰值 **%d** 套(%s)\n\n", $cumulativeAvg, $cumulativeMax, $cumulativeMaxDate);
|
|
|
+ echo '<div class="weekly-chart">';
|
|
|
+ echo $dotChartSvg;
|
|
|
+ echo "</div>\n\n";
|
|
|
+}
|
|
|
+
|
|
|
// 老师列表汇总:先给出增/降总体倾向,再给出异常清单
|
|
|
$calcPct = static function (int $delta, int $prev): ?float {
|
|
|
if ($prev <= 0) {
|