From bdcd315656cf31777522ed1f7c9f72668d377903 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期一, 05 十月 2026 06:36:07 +0800
Subject: [PATCH] fix(holiday): 走势图折线数值标签回到贴近本点并加白描边

---
 HANDOFF.md                                                                                        |   24 ++++
 traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java |  230 +++++++++++++++++++++++++++++++++++++--------
 2 files changed, 212 insertions(+), 42 deletions(-)

diff --git a/HANDOFF.md b/HANDOFF.md
index 0a0cf2c..4ce3279 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -5,6 +5,30 @@
 > **鍗忎綔绾﹀畾锛�2026-08-31锛�**锛氳銆屾彁浜ゃ��= 鏈湴 commit + 鎺ㄩ�佸埌杩滅 git锛坥rigin main锛孏itblit锛夛紝涓嶅啀鍙仛鏈湴 commit銆�
 
 ## 鏈�鏂帮紙2026-10-04锛�
+### 10-04 娣卞锛氭姌绾挎暟鍊兼爣绛惧洖鍒拌创杩戞湰鐐癸紙v35锛屽緟鐢ㄦ埛楠屾敹锛�
+
+- **鐢ㄦ埛鍙嶉**锛歷31銆岄伩寮�鎶樼嚎銆嶇増鏈笉濡備笂涓�鐗� v27 鈥斺�� 铏界劧鏍囩涓嶅帇绾夸簡锛屼絾寰堝鏍囩绂昏嚜宸辩殑鏁版嵁鐐瑰お杩滐紝涓�鐪肩湅涓嶅嚭鏍囩殑鏄摢涓偣銆傝姹傚洖鍒拌创杩戞湰鐐圭殑鏁堟灉銆�
+- **鏂伴棶棰樹笌鏃ч渶姹傜殑鍐茬獊**锛氭洿鏃╃殑鍘熷璇夋眰鏄�屾暟瀛楀帇绾跨湅涓嶆竻銆嶏紱杩欐璇夋眰鏄�屾暟瀛楀繀椤昏创杩戞湰鐐广�嶃�備袱鑰呭苟瀛橈紝蹇呴』鍚屾椂婊¤冻銆�
+- **瀹炵幇锛坄HolidayTrendChartService`锛�**锛�
+  - 鏍囩鎺掔増**瀹屾暣鍥為��鍒� v27 绠楁硶**锛氭寜鍚屾棩鐐逛綅璺濈鍋氫唬浠凤紙`ownDistance 脳10`锛夛紝鍊欓�夊惈杩�/杩滅殑涓娿�佷笅銆佸乏銆佸彸鍙婂洓瑙掞紝鍔ㄦ�佽鍒掍繚璇佸悓鏃ユ爣绛句笌鐐逛綅涓婁笅椤哄簭涓�鑷达紙`lineLabelCost`銆乣lineLabelCandidates`锛夈�倂31 鐨勭嚎鏉℃帺鑶溿�丆hamfer 璺濈鍦恒�侀鑹蹭翰鍜岄」鍏ㄩ儴绉婚櫎銆�
+  - **鏂板绮楃櫧鎻忚竟锛坔alo锛�** 瑙e喅銆屽帇绾跨湅涓嶆竻銆嶏細`drawLabelWithHalo` 鐢� `TextLayout` 鍙栧瓧褰㈣疆寤擄紝鍏堜互鐧借壊 `HALO_OUTLINE_WIDTH=4.0f` 鍦嗚鎻忚竟锛屽啀鐢ㄦ湰鑹插~鍏呫�傛暟瀛楀嵆浣胯惤鍦ㄦ姌绾夸笂涔熸竻鏅板彲璇伙紝浠庤�屽厑璁告爣绛捐创鍥炴湰鐐广�傛姌绾垮浘銆佸爢鍙犲浘澶栫疆鏍囩銆侀ゼ鍥炬爣绛剧粺涓�璧拌鏂规硶銆�
+  - 椤哄甫娓呯悊璋冭瘯閬楃暀锛氬垹闄� `LABEL_DEBUG`/`CHART_SEQ`/`TRENDDBG` 鎵撳嵃锛屼互鍙婁笉鍐嶄娇鐢ㄧ殑 `lineNormal(...)` 涓� `DayLabel.normalX/normalY`銆�
+- **閲忓寲楠岃瘉锛圧apidOCR 鏍囩鍖呭洿妗嗕腑蹇� 鈫� 鐪熷疄鏁版嵁鐐瑰潗鏍囷級**锛氭爣绛惧埌鏈偣骞冲潎璺濈 v27=`36.8px`銆乿31=`53.2px`銆乿35=`36.7px`锛堣秺灏忚秺璐磋繎鏈偣锛夈�倂35 涓庣敤鎴疯鍙殑 v27 鍩烘湰涓�鑷达紝鏄庢樉浼樹簬琚惁鐨� v31銆�
+- **鍏朵綑楠岃瘉**锛歚mvn compile`/`mvn test` 閫氳繃锛堟棤缂栬瘧鍛婅閬楃暀锛夛紱Word COM 8 椤� 15 鍥撅紙1+2脳7锛夛紱RapidOCR 澶嶆 12 寮犳姌绾垮浘鏁板�兼爣绛惧寘鍥存閲嶅彔鏁� = 0锛沗label_affinity` 澶嶆鍥� 11 姣忎釜鏁版嵁鏍囩鍒版湰绾� 3~8px 涓旀湰绾挎槸鏈�杩戠嚎锛堟棤銆孋LOSER TO 鍏朵粬鑹层�嶏級锛涙斁澶� 6脳 澶嶆牳鍥� 11 宸︿笂鏍囩纭负 `630.99`锛堜綆鍒嗚鲸鐜� OCR 鏇捐璇讳负 `1630.99`锛夈�倂27鈫攙35 鍍忕礌宸害 1%锛岄泦涓湪鏍囩澶勶紝鍗崇櫧鎻忚竟鐢熸晥涓旀湭鐮村潖鎶樼嚎銆�
+- **浜х墿涓庣姸鎬�**锛歚_tmp_holiday_verify/holiday_trend_2026-10-04_v35.docx`锛涗笅杞藉壇鏈� `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏁板瓧璐磋繎鎶樼嚎-鐧借竟娓呮櫚锛�.docx`銆傛湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傚緟鐢ㄦ埛楠屾敹锛涢獙鏀跺墠涓嶆彁浜ゃ�佷笉鎺ㄩ�侊紱鐜版湁 `deploy/traffic-audit-deploy-20261004.zip` 浠嶆槸 v24 琛屼负锛岄獙鏀跺悗闇�閲嶅缓銆�
+
+### 10-04 娣卞锛氭姌绾挎暟鍊兼爣绛鹃伩寮�鎶樼嚎骞惰创杩戞湰绾匡紙v31锛屽緟鐢ㄦ埛楠屾敹锛�
+
+- **鐢ㄦ埛鍙嶉锛堥瀵肩湅鍚庯紝涓よ疆锛�**锛氣憼 鍚勫勾浠芥暟瀛楃鏈嚎鏈夎繙鏈夎繎锛屽笇鏈涜创杩戝悇鑷壊绾夸笖涓嶉敊涔憋紱鈶� 浠嶆湁鏁板瓧鍘嬪湪鎶樼嚎涓婄湅涓嶆竻锛堢涓�杞彧鏄�滆创杩戔�濓紝鏍囩浼氳惤鍦ㄧ嚎涓婏級銆傜敤鎴峰缓璁細璋冪旱杞村埢搴︽媺寮�鎶樼嚎銆佹垨鍔犲乏鍙抽伩璁╄鏍囩涓嶅帇绾裤��
+- **瀹炵幇锛坄HolidayTrendChartService`锛�**锛�
+  - 鏍囩鎺掔増鏀逛负銆屽姩鎬佽鍒� + 绾挎潯鍍忕礌閬胯銆嶃�傚浘琛ㄥ厛鐢绘姌绾垮拰鏍囪锛屽啀鎶婃墍鏈夌郴鍒楅鑹插儚绱狅紙瀹瑰樊 45锛夊仛鎴愭帺鑶滐紝瀵规瘡涓�欓�夋爣绛句綅缃粺璁″叾瑕嗙洊鐨勭嚎鏉″儚绱犳暟锛宍脳700` 閲嶇綒鈥斺�旀暟瀛椾笉鍐嶅帇绾裤��
+  - 涓烘瘡鏉$郴鍒楀缓绔� Chamfer 璺濈鍦猴紙鍒拌鑹叉渶杩戝儚绱犵殑璺濈锛夈�備唬浠锋柊澧炰袱椤癸細`max(0, 鑷繁閭f潯绾跨殑璺濈-3)脳40`锛堟妸鏍囩鎷夊洖鏈嚎鏃侊紝闃叉椋樿繙锛変笌棰滆壊浜插拰椤� `max(0, 鑷繁绾胯窛 - 鍒殑绾胯窛 + 4)脳200`锛堣姹傛湰绾挎槸鏈�杩戠殑绾匡紝閬垮厤璐村埌鍒殑绯诲垪锛夈��
+  - 鏃х殑銆岀鍒殑鍚屾棩鐐逛綅鏇磋繎銆嶆儵缃氱敱 `脳5000` 闄嶅埌 `脳400`锛岄伩鍏嶅畠鍘嬪�掍翰鍜岄」锛沗ITEM_LABEL_GAP` 12鈫�10锛涘�欓�夊惈杩�/杩滅殑涓娿�佷笅銆佸乏銆佸彸鍙婂洓瑙掋��
+  - 淇濈暀鍚屾棩鏍囩涓庢暟鎹偣涓�鑷寸殑涓婁笅椤哄簭锛岄伩鍏嶆爣绛句笌鎶樼嚎涓婁笅鍙嶈浆銆�
+- **璋冨弬杩囩▼鎻愮ず**锛歷27锛堝彧鎸夌偣浣嶈窛绂绘帓搴忥級鈫� 鏍囩鍘嬬嚎锛泇28锛堝彧鍔犺鐩栧儚绱犳儵缃氾級鈫� 鏍囩绂绘湰绾裤�佽创鍒板埆鐨勮壊绾挎梺锛泇31锛堣鐩栨儵缃� + 鏈嚎璺濈 + 棰滆壊浜插拰锛夆啋 涓よ�呭吋椤俱�傞獙璇佹爣绛炬槸鍚︹�滆创閿欑嚎鈥濅笉鑳藉彧鐪� OCR 鍖呭洿妗嗗埌寮傝壊鐨勮窛绂伙細鐩搁偦鐨勫紓鑹�**鏍囩**浼氳璇垽涓哄紓鑹�**绾�**锛屽繀椤昏倝鐪兼牳瀵规暣鍥俱��
+- **楠岃瘉**锛歚mvn test -f traffic-audit-server\pom.xml` 閫氳繃銆傜湡瀹炲簱鍙鐢熸垚 `_tmp_holiday_verify/holiday_trend_2026-10-04_v31.docx`锛沇ord COM 8 椤� 15 鍥撅紙1+2脳7锛夈�俁apidOCR 澶嶆 15 寮犲浘鏁板�兼爣绛惧寘鍥存閲嶅彔鏁颁负 0銆傝倝鐪兼牳瀵瑰浘 1锛堣法鍖哄煙浜哄憳娴佸姩閲忥級銆佸浘 2锛堢疮璁★級銆佸浘 4锛堣嚜椹撅級銆佸浘 6锛堟按璺級銆佸浘 9锛堥珮閫燂級銆佸浘 11锛堣溅娴佹�婚噺锛夛細鏁板�兼爣绛惧潎绂诲紑鎶樼嚎銆佷綅浜庡悇鑷壊绾挎梺銆侀鑹插搴旀纭紱鍥� 11 鐨� 630.99/601.04/571.31 鍒嗗埆绱ц创钃�/绾�/缁挎姌绾裤��
+- **浜х墿涓庣姸鎬�**锛歚C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏁板�兼爣绛鹃伩寮�鎶樼嚎锛�.docx`銆傛湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傚緟鐢ㄦ埛楠屾敹锛岄獙鏀跺墠涓嶆彁浜ゃ�佷笉鎺ㄩ�侊紱鐜版湁 `deploy/traffic-audit-deploy-20261004.zip` 浠嶆槸 v24 琛屼负锛岄渶楠屾敹鍚庨噸寤恒��
+
 ### 10-04 鏅氾細璧板娍鍥� 3 椤瑰潗鏍囪酱璋冩暣锛坴24锛岀敤鎴峰凡楠屾敹锛�
 
 - **鐢ㄦ埛淇敼椤�**锛氣憼 鍧愭爣杞村崟浣嶏紙涓囦汉娆°�佷竾杈嗘銆佸叕閲�/灏忔椂銆佹嫢鎸ゅ害銆佹棩鏈燂級鏀规垚鍜屽埢搴︿竴鏍风殑棰滆壊锛涒憽 涓や釜鍫嗗彔鍥剧殑鍧愭爣杞存敼鎴愪笌鍏朵粬鍥句竴鏍风殑榛戣壊锛涒憿 鈥滀竾浜烘/鍗犳瘮鈥濈户缁斁鍦ㄥ爢鍙犲浘鍙充笂瑙掋��
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
index 8d359c4..4343f08 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -74,7 +74,8 @@
     private static final Color MUTED_TEXT_COLOR = new Color(102, 102, 102);
     private static final double UNIT_LABEL_BAND = 58.0;
     private static final double RIGHT_LABEL_BAND = 74.0;
-    private static final double ITEM_LABEL_GAP = 12.0;
+    private static final double ITEM_LABEL_GAP = 10.0;
+    private static final float HALO_OUTLINE_WIDTH = 4.0f;
     private static final Color ITEM_LABEL_COLOR = new Color(55, 55, 55);
     private static final Color[] SERIES_COLORS = {
             new Color(192, 80, 77),
@@ -841,7 +842,6 @@
         graphics.setFont(ITEM_LABEL_FONT);
         FontMetrics metrics = graphics.getFontMetrics();
         List<Rectangle2D> placedLabels = new ArrayList<>();
-        List<Rectangle2D> markers = new ArrayList<>();
         if (unitLabel != null && !unitLabel.isEmpty()) {
             placedLabels.add(new Rectangle2D.Double(0.0, 0.0,
                     dataArea.getMinX() + 100.0, dataArea.getMinY() + 30.0));
@@ -849,13 +849,6 @@
         int itemCount = 0;
         for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) {
             itemCount = Math.max(itemCount, dataset.getItemCount(seriesIndex));
-            for (int itemIndex = 0; itemIndex < dataset.getItemCount(seriesIndex); itemIndex++) {
-                Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea);
-                if (point != null) {
-                    markers.add(new Rectangle2D.Double(point.getX() - 9.0,
-                            point.getY() - 9.0, 18.0, 18.0));
-                }
-            }
         }
 
         for (int itemIndex = 0; itemIndex < itemCount; itemIndex++) {
@@ -876,35 +869,95 @@
                     return Double.compare(first.getPoint().getY(), second.getPoint().getY());
                 }
             });
-            double previousBottom = Double.NEGATIVE_INFINITY;
+            List<List<LineLabelCandidate>> candidateSets = new ArrayList<>();
             for (DayLabel label : labels) {
-                int width = metrics.stringWidth(label.getText());
-                int height = metrics.getHeight();
-                double x = label.getPoint().getX() - width / 2.0;
-                double top = Math.max(label.getPoint().getY() - ITEM_LABEL_GAP - height,
-                        previousBottom + 3.0);
-                Rectangle2D chosen = null;
-                double bestPenalty = Double.MAX_VALUE;
-                for (int step = 0; step <= 4; step++) {
-                    Rectangle2D candidate = boundToDataArea(new Rectangle2D.Double(
-                            x, top + step * (height + 4.0), width, height), dataArea);
-                    if (candidate == null) continue;
-                    double penalty = overlapArea(candidate, placedLabels) * 1000.0
-                            + overlapArea(candidate, markers);
-                    if (penalty < bestPenalty) {
-                        bestPenalty = penalty;
-                        chosen = candidate;
-                    }
-                    if (penalty <= 0.0) break;
+                List<LineLabelCandidate> candidates = lineLabelCandidates(
+                        label, metrics, dataArea);
+                if (candidates.isEmpty()) {
+                    candidateSets.clear();
+                    break;
                 }
-                if (chosen == null) continue;
+                candidateSets.add(candidates);
+            }
+            if (candidateSets.isEmpty()) continue;
+
+            // Dynamic programming keeps each label near its own point while preserving
+            // the same top-to-bottom order as the data points for that date.
+            List<int[]> backPointers = new ArrayList<>();
+            List<LineLabelCandidate> firstCandidates = candidateSets.get(0);
+            double[] previousCosts = new double[firstCandidates.size()];
+            for (int i = 0; i < firstCandidates.size(); i++) {
+                previousCosts[i] = lineLabelCost(firstCandidates.get(i), labels.get(0),
+                        labels, placedLabels);
+            }
+
+            for (int labelIndex = 1; labelIndex < candidateSets.size(); labelIndex++) {
+                List<LineLabelCandidate> previousCandidates = candidateSets.get(labelIndex - 1);
+                List<LineLabelCandidate> currentCandidates = candidateSets.get(labelIndex);
+                double[] currentCosts = new double[currentCandidates.size()];
+                Arrays.fill(currentCosts, Double.MAX_VALUE);
+                int[] back = new int[currentCandidates.size()];
+                Arrays.fill(back, -1);
+                for (int currentIndex = 0; currentIndex < currentCandidates.size(); currentIndex++) {
+                    LineLabelCandidate current = currentCandidates.get(currentIndex);
+                    double currentCost = lineLabelCost(current, labels.get(labelIndex),
+                            labels, placedLabels);
+                    for (int previousIndex = 0; previousIndex < previousCandidates.size(); previousIndex++) {
+                        if (previousCosts[previousIndex] == Double.MAX_VALUE) continue;
+                        LineLabelCandidate previous = previousCandidates.get(previousIndex);
+                        if (previous.getRectangle().getCenterY()
+                                > current.getRectangle().getCenterY() + 0.1) {
+                            continue;
+                        }
+                        double overlapPenalty = overlapArea(previous.getRectangle(),
+                                Collections.singletonList(current.getRectangle())) * 1000000.0;
+                        double cost = previousCosts[previousIndex] + currentCost + overlapPenalty;
+                        if (cost < currentCosts[currentIndex]) {
+                            currentCosts[currentIndex] = cost;
+                            back[currentIndex] = previousIndex;
+                        }
+                    }
+                }
+                previousCosts = currentCosts;
+                backPointers.add(back);
+            }
+
+            int bestLastIndex = -1;
+            double bestCost = Double.MAX_VALUE;
+            for (int i = 0; i < previousCosts.length; i++) {
+                if (previousCosts[i] < bestCost) {
+                    bestCost = previousCosts[i];
+                    bestLastIndex = i;
+                }
+            }
+            if (bestLastIndex < 0) continue;
+
+            int[] chosenIndexes = new int[candidateSets.size()];
+            chosenIndexes[chosenIndexes.length - 1] = bestLastIndex;
+            for (int labelIndex = chosenIndexes.length - 2; labelIndex >= 0; labelIndex--) {
+                int nextIndex = chosenIndexes[labelIndex + 1];
+                chosenIndexes[labelIndex] = backPointers.get(labelIndex)[nextIndex];
+            }
+            for (int labelIndex = 0; labelIndex < chosenIndexes.length; labelIndex++) {
+                DayLabel label = labels.get(labelIndex);
+                Rectangle2D chosen = candidateSets.get(labelIndex)
+                        .get(chosenIndexes[labelIndex]).getRectangle();
                 drawLabelWithHalo(graphics, label.getText(), chosen, metrics,
                         seriesColor(label.getSeriesIndex()));
-                placedLabels.add(new Rectangle2D.Double(chosen.getX() - 5.0, chosen.getY() - 5.0,
-                        chosen.getWidth() + 10.0, chosen.getHeight() + 10.0));
-                previousBottom = chosen.getMaxY();
+                placedLabels.add(new Rectangle2D.Double(chosen.getX() - 4.0, chosen.getY() - 4.0,
+                        chosen.getWidth() + 8.0, chosen.getHeight() + 8.0));
             }
         }
+    }
+
+    private static class LineLabelCandidate {
+        private final Rectangle2D rectangle;
+
+        private LineLabelCandidate(Rectangle2D rectangle) {
+            this.rectangle = rectangle;
+        }
+
+        private Rectangle2D getRectangle() { return rectangle; }
     }
 
     private static class DayLabel {
@@ -937,10 +990,99 @@
         return new Point2D.Double(x, y);
     }
 
-    private void addLabelCandidate(List<Rectangle2D> result, double x, double y,
-                                   int width, int height, double dx, double dy) {
-        result.add(new Rectangle2D.Double(x + dx - width / 2.0,
-                y + dy - height / 2.0, width, height));
+    private List<LineLabelCandidate> lineLabelCandidates(DayLabel label, FontMetrics metrics,
+                                                         Rectangle2D dataArea) {
+        List<LineLabelCandidate> result = new ArrayList<>();
+        int width = metrics.stringWidth(label.getText());
+        int height = metrics.getHeight();
+        double x = label.getPoint().getX();
+        double y = label.getPoint().getY();
+        double horizontalOffset = ITEM_LABEL_GAP + width / 2.0;
+        double farHorizontalOffset = horizontalOffset + 18.0;
+        double verticalOffset = ITEM_LABEL_GAP + height / 2.0;
+        double farVerticalOffset = verticalOffset + height + 6.0;
+        addLabelCandidate(result, x, y, width, height, 0.0, -verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height, 0.0, verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height, horizontalOffset, 0.0, dataArea);
+        addLabelCandidate(result, x, y, width, height, -horizontalOffset, 0.0, dataArea);
+        addLabelCandidate(result, x, y, width, height, farHorizontalOffset, 0.0, dataArea);
+        addLabelCandidate(result, x, y, width, height, -farHorizontalOffset, 0.0, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                horizontalOffset, -verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                horizontalOffset, verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                -horizontalOffset, -verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                -horizontalOffset, verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                farHorizontalOffset, -verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                farHorizontalOffset, verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                -farHorizontalOffset, -verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                -farHorizontalOffset, verticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height, 0.0, -farVerticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height, 0.0, farVerticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                horizontalOffset, -farVerticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                horizontalOffset, farVerticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                -horizontalOffset, -farVerticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                -horizontalOffset, farVerticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                farHorizontalOffset, -farVerticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                farHorizontalOffset, farVerticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                -farHorizontalOffset, -farVerticalOffset, dataArea);
+        addLabelCandidate(result, x, y, width, height,
+                -farHorizontalOffset, farVerticalOffset, dataArea);
+        return result;
+    }
+
+    private void addLabelCandidate(List<LineLabelCandidate> result, double x, double y,
+                                   int width, int height, double dx, double dy,
+                                   Rectangle2D dataArea) {
+        Rectangle2D candidate = boundToDataArea(new Rectangle2D.Double(
+                x + dx - width / 2.0, y + dy - height / 2.0, width, height), dataArea);
+        if (candidate == null) return;
+        for (LineLabelCandidate existing : result) {
+            if (Math.abs(existing.getRectangle().getX() - candidate.getX()) < 0.5
+                    && Math.abs(existing.getRectangle().getY() - candidate.getY()) < 0.5) {
+                return;
+            }
+        }
+        result.add(new LineLabelCandidate(candidate));
+    }
+
+    private double lineLabelCost(LineLabelCandidate candidate, DayLabel label,
+                                List<DayLabel> sameDayLabels,
+                                List<Rectangle2D> placedLabels) {
+        Rectangle2D rectangle = candidate.getRectangle();
+        double dx = rectangle.getCenterX() - label.getPoint().getX();
+        double dy = rectangle.getCenterY() - label.getPoint().getY();
+        double ownDistance = Math.sqrt(dx * dx + dy * dy);
+        double cost = ownDistance * 10.0 + overlapArea(rectangle, placedLabels) * 10000.0;
+        Rectangle2D ownMarker = new Rectangle2D.Double(label.getPoint().getX() - 9.0,
+                label.getPoint().getY() - 9.0, 18.0, 18.0);
+        if (rectangle.intersects(ownMarker)) cost += 3000.0;
+        for (DayLabel other : sameDayLabels) {
+            if (other == label) continue;
+            double otherDx = rectangle.getCenterX() - other.getPoint().getX();
+            double otherDy = rectangle.getCenterY() - other.getPoint().getY();
+            double otherDistance = Math.sqrt(otherDx * otherDx + otherDy * otherDy);
+            if (otherDistance < ownDistance - 1.0) {
+                cost += (ownDistance - otherDistance) * 5000.0;
+            }
+            Rectangle2D otherMarker = new Rectangle2D.Double(other.getPoint().getX() - 9.0,
+                    other.getPoint().getY() - 9.0, 18.0, 18.0);
+            if (rectangle.intersects(otherMarker)) cost += 2000.0;
+        }
+        return cost;
     }
 
     private Rectangle2D boundToDataArea(Rectangle2D rectangle, Rectangle2D dataArea) {
@@ -979,14 +1121,18 @@
                                    FontMetrics metrics, Color color) {
         int x = (int) Math.round(rectangle.getX());
         int y = (int) Math.round(rectangle.getY() + metrics.getAscent());
+        // A stroked white outline keeps the digit legible even when a line runs
+        // underneath it, which lets labels stay right next to their own point.
+        java.awt.font.TextLayout layout = new java.awt.font.TextLayout(
+                text, graphics.getFont(), graphics.getFontRenderContext());
+        java.awt.Shape outline = layout.getOutline(
+                java.awt.geom.AffineTransform.getTranslateInstance(x, y));
         graphics.setColor(Color.WHITE);
-        for (int dx = -1; dx <= 1; dx++) {
-            for (int dy = -1; dy <= 1; dy++) {
-                if (dx != 0 || dy != 0) graphics.drawString(text, x + dx, y + dy);
-            }
-        }
+        graphics.setStroke(new BasicStroke(HALO_OUTLINE_WIDTH,
+                BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+        graphics.draw(outline);
         graphics.setColor(color);
-        graphics.drawString(text, x, y);
+        graphics.fill(outline);
     }
 
     public static final List<String> DAY_LABELS = Arrays.asList(

--
Gitblit v1.9.1