Explorar o código

feat(report): add daily paper count dot chart from Jan 6 to now

Add a scatter/dot chart showing daily paper assembly count from 2026-01-06
to present. Dots above average are highlighted in blue, below in light
blue, with a dashed yellow average line. Includes summary stats (total
days, cumulative count, daily average, peak day).

Co-authored-by: Cursor <cursoragent@cursor.com>
yemeishu hai 4 días
pai
achega
d8928a8b23
Modificáronse 1 ficheiros con 157 adicións e 0 borrados
  1. 157 0
      scripts/report_teacher_weekly_stats.php

+ 157 - 0
scripts/report_teacher_weekly_stats.php

@@ -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) {