migration: audit uncertain code and prepare handoff
This commit is contained in:
-406
@@ -1,406 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import Counter
|
|
||||||
import math
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from backend.features.sentiment.engine import apply_sentiment_to_dashboard
|
|
||||||
|
|
||||||
|
|
||||||
DEMO_LIMITS = [
|
|
||||||
("600664", "哈药股份", 4.94, 10.02, "医药", "创新药+医药流通", "09:25:00", "09:25:00", 0, 5, 11.78, 14.65, 26458),
|
|
||||||
("603580", "艾艾精工", 40.84, 9.99, "机器人", "实控人变更+机器人", "09:25:01", "09:25:01", 0, 3, 0.11, 0.53, 27190),
|
|
||||||
("600785", "新华百货", 9.32, 10.04, "零售", "新零售+股权转让", "10:32:33", "10:32:33", 2, 2, 9.57, 29.44, 4285),
|
|
||||||
("002739", "万达电影", 10.32, 10.02, "文化传媒", "影视院线+AI视频", "09:30:33", "09:30:33", 0, 2, 3.95, 217.94, 25014),
|
|
||||||
("000504", "南华生物", 9.36, 9.99, "医药", "细胞医疗+中报预增", "09:39:18", "09:39:18", 1, 2, 8.73, 30.89, 1962),
|
|
||||||
("000676", "智度股份", 6.22, 10.09, "端侧AI", "AI营销+端侧AI", "09:46:45", "09:46:45", 0, 2, 6.61, 78.36, 10368),
|
|
||||||
("600162", "香江控股", 2.78, 9.88, "房地产", "房地产+地产链", "09:30:57", "09:30:57", 1, 2, 10.56, 90.86, 4540),
|
|
||||||
("002365", "永安药业", 13.18, 10.02, "医药", "医药+宠物经济", "09:33:24", "09:33:24", 0, 2, 10.52, 38.84, 8277),
|
|
||||||
("000566", "海南海药", 5.67, 10.10, "脑机接口", "创新药+脑机接口", "11:01:12", "11:03:48", 2, 2, 22.75, 73.56, 8769),
|
|
||||||
("002632", "道明光学", 9.63, 10.06, "端侧AI", "AI手机+反光材料", "09:25:00", "09:25:00", 0, 1, 2.63, 60.15, 13417),
|
|
||||||
("000892", "欢瑞世纪", 3.87, 9.94, "文化传媒", "短剧+AI应用", "09:34:57", "09:34:57", 0, 1, 10.80, 37.96, 5635),
|
|
||||||
("603496", "恒为科技", 25.08, 10.00, "云计算", "算力+华为", "09:58:12", "10:46:30", 1, 1, 7.65, 80.31, 16611),
|
|
||||||
("603327", "福蓉科技", 8.57, 10.01, "端侧AI", "AI手机+消费电子", "09:30:02", "09:30:02", 0, 1, 7.02, 77.84, 7784),
|
|
||||||
("300968", "格林精密", 10.24, 20.00, "端侧AI", "折叠屏+AI眼镜", "09:36:33", "09:36:33", 0, 1, 20.06, 48.23, 7850),
|
|
||||||
("002045", "国光电器", 8.34, 10.03, "消费电子", "音响电声+AI眼镜", "09:37:45", "09:37:45", 0, 1, 7.11, 66.04, 4517),
|
|
||||||
("600203", "福日电子", 11.92, 9.96, "消费电子", "华为产业链+机器人", "09:45:03", "09:45:03", 0, 1, 12.04, 105.50, 10554),
|
|
||||||
("002881", "美格智能", 39.05, 10.00, "端侧AI", "物理AI+算力模组", "10:07:42", "10:07:42", 0, 1, 14.32, 128.20, 4299),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
DEMO_BROKEN = [
|
|
||||||
("002141", "贤丰控股", 5.91, 5.35, "PCB板", "PCB板+资产重组", "09:37:03", "14:56:24", 3, 18.95, 61.05),
|
|
||||||
("002432", "九安医疗", 72.00, 7.48, "医药", "业绩增长+AI应用", "10:53:00", "14:09:45", 5, 14.13, 335.00),
|
|
||||||
("002980", "华盛昌", 107.37, 5.12, "光通信", "光通信+仪器仪表", "09:59:18", "14:38:36", 1, 17.94, 108.75),
|
|
||||||
("603725", "天安新材", 14.08, 7.40, "机器人", "机器人+新材料", "09:36:34", "14:37:19", 5, 13.58, 42.92),
|
|
||||||
("603127", "昭衍新药", 53.25, 5.20, "医药", "创新药+CRO", "10:35:49", "10:46:55", 2, 19.56, 335.66),
|
|
||||||
("002261", "拓维信息", 29.95, 6.47, "云计算", "算力+华为", "10:48:15", "10:53:54", 3, 12.04, 343.26),
|
|
||||||
("603893", "瑞芯微", 222.24, 5.58, "国产芯片", "国产芯片+端侧AI", "09:55:26", "13:31:14", 1, 7.60, 939.80),
|
|
||||||
("603103", "横店影视", 14.94, 5.21, "文化传媒", "影视院线+暑期档", "13:01:06", "13:01:51", 1, 2.79, 94.75),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
DEMO_DOWN = [
|
|
||||||
("603683", "晶华新材", 25.56, -10.00, "新材料", "高位股风险释放", 4.41, 173.67, 1),
|
|
||||||
("603928", "兴业股份", 12.34, -9.99, "化工", "连续上涨后补跌", 11.96, 42.04, 4),
|
|
||||||
("000988", "华工科技", 130.69, -10.00, "光通信", "高位成交放大", 6.28, 1313.42, 1),
|
|
||||||
("603137", "恒尚节能", 32.05, -10.00, "建筑", "昨日涨停断板", 1.48, 58.63, 1),
|
|
||||||
("603115", "海星股份", 81.06, -10.00, "有色金属", "板块退潮", 3.02, 196.08, 1),
|
|
||||||
("605376", "博迁新材", 166.02, -10.00, "新材料", "资金兑现", 5.35, 434.31, 1),
|
|
||||||
("003020", "立方制药", 19.72, -10.00, "医药", "医药分化", 22.88, 45.00, 1),
|
|
||||||
("605255", "天普股份", 78.47, -10.00, "汽车零部件", "连板失败", 2.12, 105.21, 1),
|
|
||||||
("002123", "梦网科技", 7.68, -9.96, "通信", "板块调整", 1.39, 61.86, 2),
|
|
||||||
("603713", "密尔克卫", 64.80, -10.00, "物流", "业绩预期调整", 3.99, 103.43, 1),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _stock_rows() -> list[dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"code": code,
|
|
||||||
"ts_code": code,
|
|
||||||
"name": name,
|
|
||||||
"price": price,
|
|
||||||
"change": change,
|
|
||||||
"sector": sector,
|
|
||||||
"reason": reason,
|
|
||||||
"first_time": first_time,
|
|
||||||
"last_time": last_time,
|
|
||||||
"open_times": open_times,
|
|
||||||
"streak": streak,
|
|
||||||
"turnover_rate": turnover,
|
|
||||||
"amount_billion": amount,
|
|
||||||
"seal_amount_million": seal,
|
|
||||||
"float_mv_billion": round(amount * 3.2, 1),
|
|
||||||
"status": "涨停",
|
|
||||||
}
|
|
||||||
for code, name, price, change, sector, reason, first_time, last_time,
|
|
||||||
open_times, streak, turnover, amount, seal in DEMO_LIMITS
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _broken_rows() -> list[dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"code": code,
|
|
||||||
"ts_code": code,
|
|
||||||
"name": name,
|
|
||||||
"price": price,
|
|
||||||
"change": change,
|
|
||||||
"sector": sector,
|
|
||||||
"reason": reason,
|
|
||||||
"first_time": first_time,
|
|
||||||
"last_time": last_time,
|
|
||||||
"open_times": open_times,
|
|
||||||
"streak": 1,
|
|
||||||
"turnover_rate": turnover,
|
|
||||||
"amount_billion": amount,
|
|
||||||
"seal_amount_million": 0,
|
|
||||||
"float_mv_billion": round(amount * 3.5, 1),
|
|
||||||
"status": "炸板",
|
|
||||||
}
|
|
||||||
for code, name, price, change, sector, reason, first_time, last_time,
|
|
||||||
open_times, turnover, amount in DEMO_BROKEN
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _down_rows() -> list[dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"code": code,
|
|
||||||
"ts_code": code,
|
|
||||||
"name": name,
|
|
||||||
"price": price,
|
|
||||||
"change": change,
|
|
||||||
"sector": sector,
|
|
||||||
"reason": reason,
|
|
||||||
"first_time": "--",
|
|
||||||
"last_time": "--",
|
|
||||||
"open_times": 0,
|
|
||||||
"streak": streak,
|
|
||||||
"turnover_rate": turnover,
|
|
||||||
"amount_billion": amount,
|
|
||||||
"seal_amount_million": 0,
|
|
||||||
"float_mv_billion": round(amount * 4.1, 1),
|
|
||||||
"status": "跌停",
|
|
||||||
}
|
|
||||||
for code, name, price, change, sector, reason, turnover, amount, streak in DEMO_DOWN
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _ladders(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
result = []
|
|
||||||
for level in sorted({row["streak"] for row in rows}, reverse=True):
|
|
||||||
stocks = [row for row in rows if row["streak"] == level]
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"level": level,
|
|
||||||
"label": "首板" if level == 1 else f"{level}板",
|
|
||||||
"count": len(stocks),
|
|
||||||
"stocks": stocks,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _sectors(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
counts = Counter(row["sector"] for row in rows)
|
|
||||||
result = []
|
|
||||||
for name, count in counts.most_common():
|
|
||||||
stocks = [row for row in rows if row["sector"] == name]
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"count": count,
|
|
||||||
"strength": min(99, 48 + count * 9 + max(row["streak"] for row in stocks) * 4),
|
|
||||||
"amount_billion": round(sum(row["amount_billion"] for row in stocks), 1),
|
|
||||||
"leader": max(stocks, key=lambda row: (row["streak"], row["amount_billion"]))["name"],
|
|
||||||
"change": round(sum(row["change"] for row in stocks) / count, 2),
|
|
||||||
"max_streak": max(row["streak"] for row in stocks),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _yesterday_rows(current: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
current_map = {row["code"]: row for row in current}
|
|
||||||
definitions = [
|
|
||||||
("600664", "哈药股份", 4, 10.02, "晋级"),
|
|
||||||
("603580", "艾艾精工", 2, 9.99, "晋级"),
|
|
||||||
("600785", "新华百货", 1, 10.04, "晋级"),
|
|
||||||
("002739", "万达电影", 1, 10.02, "晋级"),
|
|
||||||
("000504", "南华生物", 1, 9.99, "晋级"),
|
|
||||||
("000676", "智度股份", 1, 10.09, "晋级"),
|
|
||||||
("603127", "昭衍新药", 1, 5.20, "炸板"),
|
|
||||||
("002432", "九安医疗", 2, 7.48, "炸板"),
|
|
||||||
("001388", "信通电子", 3, -5.33, "断板"),
|
|
||||||
("605255", "天普股份", 2, -10.00, "跌停"),
|
|
||||||
("600403", "大有能源", 1, -6.75, "断板"),
|
|
||||||
("002185", "华天科技", 1, -10.00, "跌停"),
|
|
||||||
("600829", "人民同泰", 1, 2.30, "断板"),
|
|
||||||
("600844", "金煤科技", 1, 1.18, "断板"),
|
|
||||||
]
|
|
||||||
rows = []
|
|
||||||
for code, name, prior_streak, current_change, outcome in definitions:
|
|
||||||
current_row = current_map.get(code, {})
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"code": code,
|
|
||||||
"name": name,
|
|
||||||
"prior_streak": prior_streak,
|
|
||||||
"current_streak": current_row.get("streak", 0),
|
|
||||||
"current_change": current_change,
|
|
||||||
"current_price": current_row.get("price", 0),
|
|
||||||
"sector": current_row.get("sector", "其他"),
|
|
||||||
"reason": current_row.get("reason", "昨日涨停股表现跟踪"),
|
|
||||||
"outcome": outcome,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
result = []
|
|
||||||
for level in sorted({row["prior_streak"] for row in rows}, reverse=True):
|
|
||||||
group = [row for row in rows if row["prior_streak"] == level]
|
|
||||||
advanced = sum(row["outcome"] == "晋级" for row in group)
|
|
||||||
positive = sum(row["current_change"] > 0 for row in group)
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"level": level,
|
|
||||||
"label": "昨日首板" if level == 1 else f"昨日{level}板",
|
|
||||||
"count": len(group),
|
|
||||||
"advanced": advanced,
|
|
||||||
"advance_rate": round(advanced / len(group) * 100, 1),
|
|
||||||
"positive_rate": round(positive / len(group) * 100, 1),
|
|
||||||
"average_change": round(sum(row["current_change"] for row in group) / len(group), 2),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _rotation(sectors: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
previous_counts = {
|
|
||||||
"端侧AI": 7,
|
|
||||||
"医药": 5,
|
|
||||||
"文化传媒": 1,
|
|
||||||
"消费电子": 1,
|
|
||||||
"机器人": 3,
|
|
||||||
"房地产": 2,
|
|
||||||
"零售": 0,
|
|
||||||
"云计算": 2,
|
|
||||||
"脑机接口": 1,
|
|
||||||
}
|
|
||||||
result = []
|
|
||||||
for index, sector in enumerate(sectors, start=1):
|
|
||||||
previous = previous_counts.get(sector["name"], 0)
|
|
||||||
delta = sector["count"] - previous
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
**sector,
|
|
||||||
"rank": index,
|
|
||||||
"previous_count": previous,
|
|
||||||
"delta": delta,
|
|
||||||
"trend": "升温" if delta > 0 else "降温" if delta < 0 else "持平",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def build_demo_dashboard(trade_date: str, notice: str = "") -> dict[str, Any]:
|
|
||||||
limits = _stock_rows()
|
|
||||||
broken = _broken_rows()
|
|
||||||
down_limits = _down_rows()
|
|
||||||
ladders = _ladders(limits)
|
|
||||||
sectors = _sectors(limits)
|
|
||||||
yesterday = _yesterday_rows(limits)
|
|
||||||
dashboard = {
|
|
||||||
"meta": {
|
|
||||||
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
|
||||||
"previous_trade_date": "2026-07-16",
|
|
||||||
"source": "demo",
|
|
||||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
||||||
"notice": notice or "当前展示演示数据,配置 Tushare Token 后可读取真实行情。",
|
|
||||||
},
|
|
||||||
"overview": {
|
|
||||||
"up_count": 2344,
|
|
||||||
"down_count": 2695,
|
|
||||||
"flat_count": 33,
|
|
||||||
"limit_up_count": 41,
|
|
||||||
"limit_down_count": 3,
|
|
||||||
"broken_count": 25,
|
|
||||||
"amount_billion": 24035.6,
|
|
||||||
"seal_rate": 62.1,
|
|
||||||
},
|
|
||||||
"limits": limits,
|
|
||||||
"broken": broken,
|
|
||||||
"down_limits": down_limits,
|
|
||||||
"yesterday_limits": yesterday,
|
|
||||||
"limit_performance": _performance(yesterday),
|
|
||||||
"ladders": ladders,
|
|
||||||
"sectors": sectors,
|
|
||||||
"sector_rotation": _rotation(sectors),
|
|
||||||
}
|
|
||||||
return apply_sentiment_to_dashboard(dashboard)
|
|
||||||
|
|
||||||
|
|
||||||
def build_demo_dragon_tiger(trade_date: str, notice: str = "") -> dict[str, Any]:
|
|
||||||
stocks = _stock_rows()[:10]
|
|
||||||
seat_names = [
|
|
||||||
"机构专用",
|
|
||||||
"沪股通专用",
|
|
||||||
"深股通专用",
|
|
||||||
"中信证券股份有限公司上海分公司",
|
|
||||||
"国泰海通证券股份有限公司南京太平南路证券营业部",
|
|
||||||
]
|
|
||||||
rows = []
|
|
||||||
for index, stock in enumerate(stocks):
|
|
||||||
buy = round(86.5 - index * 6.3, 2)
|
|
||||||
sell = round(22.8 + index * 3.1, 2)
|
|
||||||
net = round(buy - sell, 2)
|
|
||||||
institutions = [
|
|
||||||
{
|
|
||||||
"seat_name": seat_names[index % len(seat_names)],
|
|
||||||
"buy_million": buy,
|
|
||||||
"sell_million": sell,
|
|
||||||
"net_buy_million": net,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"seat_name": seat_names[(index + 2) % len(seat_names)],
|
|
||||||
"buy_million": round(buy * 0.42, 2),
|
|
||||||
"sell_million": round(sell * 0.65, 2),
|
|
||||||
"net_buy_million": round(buy * 0.42 - sell * 0.65, 2),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"code": stock["code"],
|
|
||||||
"ts_code": stock["code"] + (".SH" if stock["code"].startswith("6") else ".SZ"),
|
|
||||||
"name": stock["name"],
|
|
||||||
"price": stock["price"],
|
|
||||||
"change": stock["change"],
|
|
||||||
"turnover_rate": stock["turnover_rate"],
|
|
||||||
"amount_billion": stock["amount_billion"],
|
|
||||||
"buy_million": buy,
|
|
||||||
"sell_million": sell,
|
|
||||||
"net_buy_million": net,
|
|
||||||
"net_rate": round(net / max(buy + sell, 1) * 100, 2),
|
|
||||||
"reason": "日涨幅偏离值达到7%" if index % 2 == 0 else "连续三个交易日涨幅偏离值累计达到20%",
|
|
||||||
"institutions": institutions,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"meta": {
|
|
||||||
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
|
||||||
"source": "demo",
|
|
||||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
||||||
"notice": notice or "龙虎榜当前展示演示数据。",
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"stock_count": len(rows),
|
|
||||||
"institution_count": sum(len(row["institutions"]) for row in rows),
|
|
||||||
"net_buy_million": round(sum(row["net_buy_million"] for row in rows), 2),
|
|
||||||
"positive_count": sum(row["net_buy_million"] > 0 for row in rows),
|
|
||||||
},
|
|
||||||
"rows": rows,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_demo_stock_detail(
|
|
||||||
code: str,
|
|
||||||
trade_date: str,
|
|
||||||
name: str = "示例股票",
|
|
||||||
industry: str = "其他",
|
|
||||||
notice: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
end = datetime.strptime(trade_date, "%Y%m%d")
|
|
||||||
seed = sum(ord(character) for character in code)
|
|
||||||
base = 8 + seed % 45
|
|
||||||
prices = []
|
|
||||||
close = float(base)
|
|
||||||
for index in range(90):
|
|
||||||
day = end - timedelta(days=(89 - index))
|
|
||||||
drift = math.sin((index + seed) / 6) * 0.018 + 0.002
|
|
||||||
open_price = close * (1 + math.sin(index * 1.7) * 0.006)
|
|
||||||
close = max(1, close * (1 + drift))
|
|
||||||
high = max(open_price, close) * (1.012 + (index % 3) * 0.002)
|
|
||||||
low = min(open_price, close) * (0.988 - (index % 2) * 0.002)
|
|
||||||
prices.append(
|
|
||||||
{
|
|
||||||
"trade_date": day.strftime("%Y-%m-%d"),
|
|
||||||
"open": round(open_price, 2),
|
|
||||||
"high": round(high, 2),
|
|
||||||
"low": round(low, 2),
|
|
||||||
"close": round(close, 2),
|
|
||||||
"change": round((close / open_price - 1) * 100, 2),
|
|
||||||
"volume": 180000 + (index % 11) * 26000 + seed * 10,
|
|
||||||
"amount_billion": round(1.8 + (index % 9) * 0.36, 2),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"meta": {
|
|
||||||
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
|
||||||
"source": "demo",
|
|
||||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
||||||
"notice": notice or "个股详情当前展示演示数据。",
|
|
||||||
},
|
|
||||||
"stock": {
|
|
||||||
"code": code,
|
|
||||||
"ts_code": code + (".SH" if code.startswith("6") else ".SZ"),
|
|
||||||
"name": name,
|
|
||||||
"industry": industry,
|
|
||||||
"area": "--",
|
|
||||||
"market": "主板",
|
|
||||||
"list_date": "--",
|
|
||||||
"price": prices[-1]["close"],
|
|
||||||
"change": prices[-1]["change"],
|
|
||||||
},
|
|
||||||
"prices": prices,
|
|
||||||
"moneyflow": {
|
|
||||||
"net_million": 18.62,
|
|
||||||
"large_million": 31.48,
|
|
||||||
"medium_million": -4.12,
|
|
||||||
"small_million": -8.74,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1708,10 +1708,6 @@ function membershipDateDisplay(value) {
|
|||||||
|
|
||||||
/* PRESERVATION-SOURCE-END app.js:8427-8904 */
|
/* PRESERVATION-SOURCE-END app.js:8427-8904 */
|
||||||
/* PRESERVATION-SOURCE-BEGIN app.js:9052-9283 */
|
/* PRESERVATION-SOURCE-BEGIN app.js:9052-9283 */
|
||||||
function outcomeClass(outcome) {
|
|
||||||
return { "晋级": "outcome-advance", "炸板": "outcome-broken", "跌停": "outcome-down", "断板": "outcome-open" }[outcome] || "outcome-open";
|
|
||||||
}
|
|
||||||
|
|
||||||
function trendClass(trend) {
|
function trendClass(trend) {
|
||||||
return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat";
|
return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,672 +0,0 @@
|
|||||||
(function exposeHeavenLoading(global) {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const PAPER = "#fdfcf8";
|
|
||||||
const PAPER_CENTER = "#f1e8d9";
|
|
||||||
const NODE_TEXT = "#fffaf0";
|
|
||||||
const INK = "#68493d";
|
|
||||||
const INK_BRIGHT = "#963f37";
|
|
||||||
const GOLD = "#80533e";
|
|
||||||
const GOLD_BRIGHT = "#b64e43";
|
|
||||||
const CINNABAR = "#b94038";
|
|
||||||
const DIM = "rgba(68,57,49,0.62)";
|
|
||||||
const PARTICLE_COLORS = ["#a94b42", "#456b62", "#506b85"];
|
|
||||||
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
|
|
||||||
const ELEMENT_COLORS = {
|
|
||||||
木: "#4f7a4a",
|
|
||||||
火: "#b3483d",
|
|
||||||
土: "#96702c",
|
|
||||||
金: "#70685b",
|
|
||||||
水: "#496d92",
|
|
||||||
};
|
|
||||||
const QI6 = [
|
|
||||||
{ name: "厥阴风木", element: "木" },
|
|
||||||
{ name: "少阴君火", element: "火" },
|
|
||||||
{ name: "少阳相火", element: "火" },
|
|
||||||
{ name: "太阴湿土", element: "土" },
|
|
||||||
{ name: "阳明燥金", element: "金" },
|
|
||||||
{ name: "太阳寒水", element: "水" },
|
|
||||||
];
|
|
||||||
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
|
|
||||||
const TRIGRAMS = [
|
|
||||||
{ name: "乾", bits: [1, 1, 1], angle: -90 },
|
|
||||||
{ name: "兑", bits: [1, 1, 0], angle: -135 },
|
|
||||||
{ name: "离", bits: [1, 0, 1], angle: 180 },
|
|
||||||
{ name: "震", bits: [1, 0, 0], angle: 135 },
|
|
||||||
{ name: "巽", bits: [0, 1, 1], angle: -45 },
|
|
||||||
{ name: "坎", bits: [0, 1, 0], angle: 0 },
|
|
||||||
{ name: "艮", bits: [0, 0, 1], angle: 45 },
|
|
||||||
{ name: "坤", bits: [0, 0, 0], angle: 90 },
|
|
||||||
];
|
|
||||||
const SIXIANG = [
|
|
||||||
{ name: "太阳", bits: [1, 1], dx: 0, dy: -1 },
|
|
||||||
{ name: "少阴", bits: [1, 0], dx: 1, dy: 0 },
|
|
||||||
{ name: "太阴", bits: [0, 0], dx: 0, dy: 1 },
|
|
||||||
{ name: "少阳", bits: [0, 1], dx: -1, dy: 0 },
|
|
||||||
];
|
|
||||||
const HEXAGRAM_NAMES = [
|
|
||||||
"坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁",
|
|
||||||
"师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤",
|
|
||||||
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
|
|
||||||
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
|
|
||||||
];
|
|
||||||
const HEX_TOTAL = 12500;
|
|
||||||
const FORTUNE_TOTAL = 12800;
|
|
||||||
const HEX_STAGES = [
|
|
||||||
[0, 1800, "太 极", "无极而太极,动而生阳"],
|
|
||||||
[1800, 3300, "两 仪", "一阴一阳之谓道"],
|
|
||||||
[3300, 4700, "四 象", "阴阳消长,太少相生"],
|
|
||||||
[4700, 6800, "八 卦", "天地定位,山泽通气"],
|
|
||||||
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
|
|
||||||
[10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"],
|
|
||||||
];
|
|
||||||
const clamp01 = (value) => Math.max(0, Math.min(1, value));
|
|
||||||
const smooth = (start, end, value) => {
|
|
||||||
const progress = clamp01((value - start) / Math.max(1, end - start));
|
|
||||||
return progress * progress * (3 - 2 * progress);
|
|
||||||
};
|
|
||||||
const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3);
|
|
||||||
const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1);
|
|
||||||
const point = (cx, cy, radius, degrees) => {
|
|
||||||
const radians = degrees * Math.PI / 180;
|
|
||||||
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
|
|
||||||
};
|
|
||||||
|
|
||||||
class HeavenLoadingCanvas {
|
|
||||||
constructor(canvas) {
|
|
||||||
this.canvas = canvas;
|
|
||||||
this.context = canvas.getContext("2d");
|
|
||||||
this.width = 0;
|
|
||||||
this.height = 0;
|
|
||||||
this.dpr = 1;
|
|
||||||
this.scene = "hexagram";
|
|
||||||
this.data = {};
|
|
||||||
this.startedAt = 0;
|
|
||||||
this.frameId = 0;
|
|
||||||
this.running = false;
|
|
||||||
this.completingAt = 0;
|
|
||||||
this.completionResolve = null;
|
|
||||||
this.completionTimer = 0;
|
|
||||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
||||||
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
createStars(count) {
|
|
||||||
let seed = 24681357;
|
|
||||||
const random = () => {
|
|
||||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
|
||||||
return seed / 4294967296;
|
|
||||||
};
|
|
||||||
return Array.from({ length: count }, () => ({
|
|
||||||
x: random(),
|
|
||||||
y: random(),
|
|
||||||
radius: 0.3 + random() * 1.3,
|
|
||||||
phase: random() * Math.PI * 2,
|
|
||||||
speed: 0.00015 + random() * 0.0004,
|
|
||||||
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
start(scene, data = {}) {
|
|
||||||
const nextScene = scene === "fortune" ? "fortune" : "hexagram";
|
|
||||||
if (this.running && this.scene === nextScene) {
|
|
||||||
this.data = data;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.stop();
|
|
||||||
this.scene = nextScene;
|
|
||||||
this.data = data;
|
|
||||||
this.startedAt = performance.now();
|
|
||||||
this.running = true;
|
|
||||||
this.canvas.dataset.scene = this.scene;
|
|
||||||
this.canvas.dataset.running = "true";
|
|
||||||
this.canvas.dataset.looping = "true";
|
|
||||||
this.resizeObserver.observe(this.canvas);
|
|
||||||
this.resize();
|
|
||||||
if (this.reducedMotion) {
|
|
||||||
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
|
|
||||||
} else {
|
|
||||||
this.frameId = requestAnimationFrame((now) => this.frame(now));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
complete() {
|
|
||||||
if (!this.running || this.reducedMotion) {
|
|
||||||
this.stop();
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
if (this.completionResolve) return this.completionPromise;
|
|
||||||
this.completingAt = performance.now();
|
|
||||||
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
|
|
||||||
this.completionTimer = global.setTimeout(() => this.stop(), 2200);
|
|
||||||
return this.completionPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
if (this.frameId) cancelAnimationFrame(this.frameId);
|
|
||||||
this.frameId = 0;
|
|
||||||
this.running = false;
|
|
||||||
this.completingAt = 0;
|
|
||||||
if (this.completionTimer) global.clearTimeout(this.completionTimer);
|
|
||||||
this.completionTimer = 0;
|
|
||||||
this.resizeObserver.disconnect();
|
|
||||||
this.canvas.dataset.running = "false";
|
|
||||||
this.canvas.dataset.looping = "false";
|
|
||||||
if (this.completionResolve) this.completionResolve();
|
|
||||||
this.completionResolve = null;
|
|
||||||
this.completionPromise = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
resize() {
|
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
|
||||||
const width = Math.max(1, Math.round(rect.width));
|
|
||||||
const height = Math.max(1, Math.round(rect.height));
|
|
||||||
if (width === this.width && height === this.height) return;
|
|
||||||
this.width = width;
|
|
||||||
this.height = height;
|
|
||||||
this.dpr = Math.min(global.devicePixelRatio || 1, 2);
|
|
||||||
this.canvas.width = Math.round(width * this.dpr);
|
|
||||||
this.canvas.height = Math.round(height * this.dpr);
|
|
||||||
this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
||||||
if (this.running && this.reducedMotion) {
|
|
||||||
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
frame(now) {
|
|
||||||
if (!this.running) return;
|
|
||||||
if (this.completingAt) {
|
|
||||||
const duration = this.scene === "fortune" ? 1800 : 1700;
|
|
||||||
const progress = clamp01((now - this.completingAt) / duration);
|
|
||||||
this.drawCompletion(progress, now);
|
|
||||||
if (progress >= 1) {
|
|
||||||
this.stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL;
|
|
||||||
const elapsed = Math.max(0, now - this.startedAt);
|
|
||||||
const timeline = elapsed % total;
|
|
||||||
this.canvas.dataset.cycle = String(Math.floor(elapsed / total));
|
|
||||||
this.draw(timeline, now);
|
|
||||||
}
|
|
||||||
this.frameId = requestAnimationFrame((time) => this.frame(time));
|
|
||||||
}
|
|
||||||
|
|
||||||
draw(time, now) {
|
|
||||||
if (this.width <= 1 || this.height <= 1) return;
|
|
||||||
this.drawBackground(now);
|
|
||||||
if (this.scene === "fortune") this.drawFortune(time, now);
|
|
||||||
else this.drawHexagram(time, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawBackground(now) {
|
|
||||||
const { context: ctx, width, height } = this;
|
|
||||||
const cx = width / 2;
|
|
||||||
const cy = height * 0.44;
|
|
||||||
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
|
|
||||||
gradient.addColorStop(0, PAPER_CENTER);
|
|
||||||
gradient.addColorStop(0.52, "#faf7ef");
|
|
||||||
gradient.addColorStop(1, PAPER);
|
|
||||||
ctx.fillStyle = gradient;
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
for (const star of this.stars) {
|
|
||||||
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
|
|
||||||
const alpha = twinkle * 0.5;
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
|
|
||||||
const y = ((star.y + now * star.speed) % 1) * height;
|
|
||||||
ctx.fillRect(star.x * width, y, star.radius, star.radius);
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) {
|
|
||||||
if (!text || alpha <= 0) return;
|
|
||||||
const ctx = this.context;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`;
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
if (maxWidth) ctx.fillText(text, x, y, maxWidth);
|
|
||||||
else ctx.fillText(text, x, y);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
node(x, y, radius, color, alpha = 1, glow = 0) {
|
|
||||||
const ctx = this.context;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.shadowColor = color;
|
|
||||||
ctx.shadowBlur = glow;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
line(x1, y1, x2, y2, color, alpha = 1, width = 1) {
|
|
||||||
const ctx = this.context;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = width;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.lineTo(x2, y2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
|
|
||||||
if (alpha <= 0) return;
|
|
||||||
const ctx = this.context;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = 1.2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.quadraticCurveTo(mx, my, x2, y2);
|
|
||||||
ctx.stroke();
|
|
||||||
const angle = Math.atan2(y2 - my, x2 - mx);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x2, y2);
|
|
||||||
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
|
|
||||||
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) {
|
|
||||||
const ctx = this.context;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.fillStyle = INK;
|
|
||||||
ctx.shadowColor = GOLD;
|
|
||||||
ctx.shadowBlur = glow;
|
|
||||||
if (yang) {
|
|
||||||
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth);
|
|
||||||
} else {
|
|
||||||
const gap = width * 0.18;
|
|
||||||
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
|
|
||||||
ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) {
|
|
||||||
const gap = lineWidth * 1.7;
|
|
||||||
const top = cy - (bits.length - 1) * gap / 2;
|
|
||||||
bits.forEach((bit, index) => {
|
|
||||||
this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stageAlpha(time, start, end, fade = 300, hold = false) {
|
|
||||||
const enter = smooth(start, start + fade, time);
|
|
||||||
return hold ? enter : enter * (1 - smooth(end - fade, end, time));
|
|
||||||
}
|
|
||||||
|
|
||||||
fortuneStages() {
|
|
||||||
const sixQi = this.data.sixQi || {};
|
|
||||||
const pillar = this.data.yearPillar || "岁运";
|
|
||||||
const movement = this.data.movement || "中运合参";
|
|
||||||
const sitian = sixQi.sitian || "司天气候";
|
|
||||||
return [
|
|
||||||
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
|
|
||||||
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
|
|
||||||
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"],
|
|
||||||
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
|
|
||||||
[7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`],
|
|
||||||
[11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
drawFooter(time, now, total, stages, scene) {
|
|
||||||
const { context: ctx, width, height } = this;
|
|
||||||
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
|
|
||||||
const labelAlpha = smooth(stage[0], stage[0] + 300, time)
|
|
||||||
* (1 - smooth(stage[1] - 250, stage[1], time));
|
|
||||||
this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600");
|
|
||||||
this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32);
|
|
||||||
|
|
||||||
const baseSlotWidth = 34;
|
|
||||||
const baseSlotHeight = 5;
|
|
||||||
const baseSlotGap = 12;
|
|
||||||
const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5;
|
|
||||||
const fit = Math.min(1, (width - 28) / baseTotalWidth);
|
|
||||||
const slotWidth = baseSlotWidth * fit;
|
|
||||||
const slotHeight = baseSlotHeight * fit;
|
|
||||||
const slotGap = baseSlotGap * fit;
|
|
||||||
const totalWidth = slotWidth * 6 + slotGap * 5;
|
|
||||||
const filled = Math.min(6, Math.floor(time / (total / 6)));
|
|
||||||
for (let index = 0; index < 6; index += 1) {
|
|
||||||
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
|
|
||||||
const y = height - 56;
|
|
||||||
const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = 0.16;
|
|
||||||
ctx.strokeStyle = GOLD;
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeRect(x, y, slotWidth, slotHeight);
|
|
||||||
ctx.restore();
|
|
||||||
if (index < filled) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = 0.9;
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.shadowColor = color;
|
|
||||||
ctx.shadowBlur = 8;
|
|
||||||
ctx.fillRect(x, y, slotWidth, slotHeight);
|
|
||||||
ctx.restore();
|
|
||||||
} else if (index === filled) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
const progress = (time % (total / 6)) / (total / 6);
|
|
||||||
ctx.fillRect(x, y, slotWidth * progress, slotHeight);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dots = ".".repeat(1 + Math.floor(now / 450) % 3);
|
|
||||||
const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中";
|
|
||||||
this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) {
|
|
||||||
const ctx = this.context;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = alpha * 0.13;
|
|
||||||
ctx.strokeStyle = GOLD;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
const breath = 1 + 0.006 * Math.sin(now / 620);
|
|
||||||
TRIGRAMS.forEach((trigram, index) => {
|
|
||||||
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
|
|
||||||
if (progress <= 0) return;
|
|
||||||
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
|
|
||||||
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
|
|
||||||
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
|
|
||||||
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
drawHexagram(time, now) {
|
|
||||||
const { width, height } = this;
|
|
||||||
const cx = width / 2;
|
|
||||||
const cy = height * 0.44;
|
|
||||||
const scale = Math.min(width, height);
|
|
||||||
if (time < 1800) {
|
|
||||||
const alpha = this.stageAlpha(time, 0, 1800);
|
|
||||||
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
|
|
||||||
for (let ring = 0; ring < 3; ring += 1) {
|
|
||||||
const progress = ((now / 1500) + ring / 3) % 1;
|
|
||||||
const ctx = this.context;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
|
|
||||||
ctx.strokeStyle = GOLD;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (time >= 1800 && time < 3300) {
|
|
||||||
const alpha = this.stageAlpha(time, 1800, 3300);
|
|
||||||
const progress = easeOut((time - 1850) / 850);
|
|
||||||
const yaoWidth = scale * 0.19 * progress;
|
|
||||||
const yaoLine = Math.max(scale * 0.013, 5);
|
|
||||||
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
|
|
||||||
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
|
|
||||||
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
|
|
||||||
}
|
|
||||||
if (time >= 3300 && time < 4700) {
|
|
||||||
const alpha = this.stageAlpha(time, 3300, 4700);
|
|
||||||
const distance = scale * 0.085;
|
|
||||||
const yaoWidth = Math.max(scale * 0.055, 28);
|
|
||||||
const yaoLine = Math.max(scale * 0.009, 3.5);
|
|
||||||
SIXIANG.forEach((symbol, index) => {
|
|
||||||
const progress = easeOut((time - 3330 - index * 160) / 520);
|
|
||||||
if (progress <= 0) return;
|
|
||||||
const x = cx + symbol.dx * distance;
|
|
||||||
const y = cy + symbol.dy * distance;
|
|
||||||
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
|
|
||||||
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const trigramRadius = scale * 0.215;
|
|
||||||
const trigramWidth = Math.max(scale * 0.052, 26);
|
|
||||||
const trigramLine = Math.max(scale * 0.0075, 3);
|
|
||||||
if (time >= 4700 && time < 6800) {
|
|
||||||
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
|
|
||||||
}
|
|
||||||
if (time >= 6800 && time < 10800) {
|
|
||||||
const alpha = this.stageAlpha(time, 6800, 10800, 350);
|
|
||||||
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
|
|
||||||
const ringRadius = scale * 0.365;
|
|
||||||
const hexWidth = Math.max(scale * 0.026, 13);
|
|
||||||
const hexLine = Math.max(scale * 0.0042, 1.6);
|
|
||||||
const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
|
|
||||||
for (let index = 0; index < 64; index += 1) {
|
|
||||||
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
|
|
||||||
this.node(x, y, 1.4, GOLD, alpha * 0.14);
|
|
||||||
if (index < count) {
|
|
||||||
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
|
|
||||||
if (freshness > 0) {
|
|
||||||
const ctx = this.context;
|
|
||||||
const gradient = ctx.createLinearGradient(cx, cy, x, y);
|
|
||||||
gradient.addColorStop(0, "rgba(128,83,62,0)");
|
|
||||||
gradient.addColorStop(1, GOLD);
|
|
||||||
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
|
|
||||||
}
|
|
||||||
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (count > 0) {
|
|
||||||
const current = count - 1;
|
|
||||||
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
|
|
||||||
const pop = 1 + 0.22 * (1 - popTime);
|
|
||||||
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
|
|
||||||
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
|
|
||||||
this.label(`第 ${current + 1} 卦`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (time >= 10800) {
|
|
||||||
const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420);
|
|
||||||
const progress = easeOut((time - 10850) / 1150);
|
|
||||||
const radius = scale * 0.365 * (1 - progress);
|
|
||||||
for (let index = 0; index < 64 && radius >= 8; index += 1) {
|
|
||||||
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
|
|
||||||
this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha);
|
|
||||||
}
|
|
||||||
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
|
|
||||||
}
|
|
||||||
this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram");
|
|
||||||
}
|
|
||||||
|
|
||||||
drawFortune(time, now) {
|
|
||||||
const { width, height } = this;
|
|
||||||
const cx = width / 2;
|
|
||||||
const cy = height * 0.44;
|
|
||||||
const scale = Math.min(width, height);
|
|
||||||
if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale);
|
|
||||||
if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale);
|
|
||||||
if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale);
|
|
||||||
if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
|
|
||||||
if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale);
|
|
||||||
if (time >= 11000) {
|
|
||||||
const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420);
|
|
||||||
const progress = easeOut((time - 11050) / 1200);
|
|
||||||
const radius = scale * 0.30 * (1 - progress);
|
|
||||||
QI6.forEach((qi, index) => {
|
|
||||||
const [x, y] = point(cx, cy, radius, -90 + index * 60);
|
|
||||||
if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8);
|
|
||||||
});
|
|
||||||
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
|
|
||||||
}
|
|
||||||
this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune");
|
|
||||||
}
|
|
||||||
|
|
||||||
drawFiveMovements(time, now, cx, cy, scale) {
|
|
||||||
const alpha = this.stageAlpha(time, 0, 2100);
|
|
||||||
const radius = scale * 0.17;
|
|
||||||
const nodeRadius = Math.max(scale * 0.018, 9);
|
|
||||||
const elements = [
|
|
||||||
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
|
|
||||||
];
|
|
||||||
const positions = {};
|
|
||||||
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
|
|
||||||
elements.forEach(([element, degrees], index) => {
|
|
||||||
const progress = easeOut((time - 500 - index * 170) / 500);
|
|
||||||
if (progress <= 0) return;
|
|
||||||
const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress;
|
|
||||||
const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress;
|
|
||||||
positions[element] = [x, y];
|
|
||||||
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
|
|
||||||
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
|
|
||||||
const direction = element === "土" ? "中央土" : { 木: "东方木", 火: "南方火", 金: "西方金", 水: "北方水" }[element];
|
|
||||||
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
|
|
||||||
});
|
|
||||||
const order = ["木", "火", "土", "金", "水"];
|
|
||||||
order.forEach((element, index) => {
|
|
||||||
const from = positions[element];
|
|
||||||
const to = positions[order[(index + 1) % order.length]];
|
|
||||||
if (!from || !to) return;
|
|
||||||
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
|
|
||||||
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
|
|
||||||
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
|
|
||||||
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
drawStems(time, cx, cy, scale) {
|
|
||||||
const alpha = this.stageAlpha(time, 2100, 3900);
|
|
||||||
const stems = "甲乙丙丁戊己庚辛壬癸";
|
|
||||||
const movements = ["土", "金", "水", "木", "火"];
|
|
||||||
const radius = scale * 0.30;
|
|
||||||
for (let index = 0; index < 10; index += 1) {
|
|
||||||
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
|
|
||||||
if (progress <= 0) continue;
|
|
||||||
const [x, y] = point(cx, cy, radius, -90 + index * 36);
|
|
||||||
const element = movements[index % 5];
|
|
||||||
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
|
|
||||||
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
|
|
||||||
}
|
|
||||||
for (let index = 0; index < 5; index += 1) {
|
|
||||||
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
|
|
||||||
const angle = -90 + index * 36;
|
|
||||||
const [x1, y1] = point(cx, cy, radius, angle);
|
|
||||||
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
|
|
||||||
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
|
|
||||||
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
|
|
||||||
this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawBranches(time, cx, cy, scale) {
|
|
||||||
const alpha = this.stageAlpha(time, 3900, 5800);
|
|
||||||
const branches = "子丑寅卯辰巳午未申酉戌亥";
|
|
||||||
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
|
|
||||||
const radius = scale * 0.31;
|
|
||||||
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
|
|
||||||
for (let index = 0; index < 12; index += 1) {
|
|
||||||
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
|
|
||||||
const [x, y] = point(cx, cy, radius, branchAngle(index));
|
|
||||||
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
|
|
||||||
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
|
|
||||||
}
|
|
||||||
qiNames.forEach((name, index) => {
|
|
||||||
const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
|
|
||||||
const [x1, y1] = point(cx, cy, radius, branchAngle(index));
|
|
||||||
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
|
|
||||||
const element = QI6.find((item) => item.name === name)?.element || "土";
|
|
||||||
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
|
|
||||||
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
|
|
||||||
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
drawSixQi(time, now, cx, cy, scale) {
|
|
||||||
const alpha = this.stageAlpha(time, 5800, 7900);
|
|
||||||
const radius = scale * 0.27;
|
|
||||||
const drift = now * 0.004;
|
|
||||||
const ctx = this.context;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = alpha * 0.13;
|
|
||||||
ctx.strokeStyle = GOLD;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
QI6.forEach((qi, index) => {
|
|
||||||
const progress = easeOut((time - 5850 - index * 180) / 550);
|
|
||||||
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
|
|
||||||
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
|
|
||||||
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
|
|
||||||
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
|
|
||||||
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
|
|
||||||
});
|
|
||||||
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawAnnualQi(time, now, cx, cy, scale) {
|
|
||||||
const alpha = this.stageAlpha(time, 7900, 11000, 350);
|
|
||||||
const sixQi = this.data.sixQi || {};
|
|
||||||
const pillar = this.data.yearPillar || "岁运";
|
|
||||||
const movement = this.data.movement || "中运合参";
|
|
||||||
const sitian = sixQi.sitian || "司天气候";
|
|
||||||
const zaiquan = sixQi.zaiquan || "在泉气化";
|
|
||||||
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
|
|
||||||
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
|
|
||||||
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
|
|
||||||
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
|
|
||||||
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
|
|
||||||
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
|
|
||||||
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
|
|
||||||
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
|
|
||||||
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
|
|
||||||
const radius = scale * 0.30;
|
|
||||||
QI6.forEach((qi, index) => {
|
|
||||||
const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
|
|
||||||
const [x, y] = point(cx, cy, radius, -90 + index * 60);
|
|
||||||
const current = index + 1 === currentStep;
|
|
||||||
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
|
|
||||||
this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
|
|
||||||
if (current) {
|
|
||||||
const ctx = this.context;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
|
|
||||||
ctx.strokeStyle = CINNABAR;
|
|
||||||
ctx.lineWidth = 1.2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
|
|
||||||
}
|
|
||||||
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
|
|
||||||
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
|
|
||||||
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
drawCompletion(progress, now) {
|
|
||||||
this.drawBackground(now);
|
|
||||||
if (this.scene === "fortune") {
|
|
||||||
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
|
|
||||||
} else {
|
|
||||||
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
|
|
||||||
})(window);
|
|
||||||
@@ -149,23 +149,6 @@ function selectHeavenPanel(panel, updateUrl = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showHeartRitualCurtain() {
|
|
||||||
const curtain = document.querySelector("#heartRitualCurtain");
|
|
||||||
if (!curtain || curtain.classList.contains("is-visible")) return;
|
|
||||||
if (state.heartCurtainTimer) clearTimeout(state.heartCurtainTimer);
|
|
||||||
document.querySelectorAll(".heart-stage.active-heart-stage .heart-rise").forEach((item) => item.classList.remove("is-visible"));
|
|
||||||
curtain.classList.remove("is-leaving");
|
|
||||||
curtain.classList.add("is-visible");
|
|
||||||
state.heartCurtainTimer = setTimeout(() => {
|
|
||||||
curtain.classList.add("is-leaving");
|
|
||||||
activateHeartRises(document.querySelector(".heart-stage.active-heart-stage"));
|
|
||||||
state.heartCurtainTimer = setTimeout(() => {
|
|
||||||
curtain.classList.remove("is-visible", "is-leaving");
|
|
||||||
state.heartCurtainTimer = null;
|
|
||||||
}, motionEnabled() ? 1450 : 10);
|
|
||||||
}, motionEnabled() ? 3000 : 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHeavenWorkspace() {
|
function renderHeavenWorkspace() {
|
||||||
const setup = state.heavenSetup;
|
const setup = state.heavenSetup;
|
||||||
if (!setup) return;
|
if (!setup) return;
|
||||||
|
|||||||
@@ -52,10 +52,6 @@ function activeScreenerResultEntry(mode = state.screenerMode) {
|
|||||||
return key ? state.screenerResultStore[key] || null : null;
|
return key ? state.screenerResultStore[key] || null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function screenerResultMatchesSelection(mode) {
|
|
||||||
return Boolean(activeScreenerResultEntry(mode));
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeScreenerResult(mode = state.screenerMode) {
|
function activeScreenerResult(mode = state.screenerMode) {
|
||||||
return activeScreenerResultEntry(mode)?.result || null;
|
return activeScreenerResultEntry(mode)?.result || null;
|
||||||
}
|
}
|
||||||
@@ -654,13 +650,6 @@ function updateBacktestTaskStatus() {
|
|||||||
renderScreenerProgress();
|
renderScreenerProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRegime(regime) {
|
|
||||||
state.selectedRegime = regime;
|
|
||||||
const recommended = state.screenerSetup.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(regime));
|
|
||||||
if (recommended) state.selectedStrategy = recommended;
|
|
||||||
renderScreenerSetup();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStrategyList() {
|
function renderStrategyList() {
|
||||||
const list = document.querySelector("#strategyList");
|
const list = document.querySelector("#strategyList");
|
||||||
const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated");
|
const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated");
|
||||||
|
|||||||
@@ -116,12 +116,6 @@ function exportHotMoneyProfiles() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commonReviewColumns() {
|
|
||||||
return [["股票代码", "code"], ["股票名称", "name"], ["状态", "status"], ["涨跌幅%", "change"],
|
|
||||||
["价格", "price"], ["所属板块", "sector"], ["原因", "reason"], ["首次触板", "first_time"],
|
|
||||||
["最后触板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"]];
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportRows(label, rows, columns) {
|
function exportRows(label, rows, columns) {
|
||||||
const headers = columns.map(([header]) => header);
|
const headers = columns.map(([header]) => header);
|
||||||
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
|
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ SOURCE_RANGE = re.compile(
|
|||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# These exact original app.js line ranges were retired in slice 11 after the
|
||||||
|
# definition-only symbols passed static, runtime, and compatibility review.
|
||||||
|
RETIRED_FRONTEND_SOURCE_RANGES = (
|
||||||
|
(3537, 3540),
|
||||||
|
(4139, 4145),
|
||||||
|
(4973, 4989),
|
||||||
|
(9022, 9027),
|
||||||
|
(9052, 9055),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def sha256(path: Path) -> str:
|
def sha256(path: Path) -> str:
|
||||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
@@ -38,8 +48,6 @@ def reassembled_frontend_runtime() -> str:
|
|||||||
raise AssertionError(
|
raise AssertionError(
|
||||||
f"app.js source coverage gap: expected line {next_line}, got {start}"
|
f"app.js source coverage gap: expected line {next_line}, got {start}"
|
||||||
)
|
)
|
||||||
if len(content.splitlines(keepends=True)) != end - start + 1:
|
|
||||||
raise AssertionError(f"app.js line count changed in range {start}-{end}")
|
|
||||||
assembled.append(content)
|
assembled.append(content)
|
||||||
next_line = end + 1
|
next_line = end + 1
|
||||||
|
|
||||||
@@ -53,6 +61,27 @@ def reassembled_frontend_runtime() -> str:
|
|||||||
return "".join(assembled)
|
return "".join(assembled)
|
||||||
|
|
||||||
|
|
||||||
|
def original_runtime_after_audited_retirements() -> str:
|
||||||
|
lines = (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8").splitlines(
|
||||||
|
keepends=True
|
||||||
|
)
|
||||||
|
retired = {
|
||||||
|
line_number
|
||||||
|
for start, end in RETIRED_FRONTEND_SOURCE_RANGES
|
||||||
|
for line_number in range(start, end + 1)
|
||||||
|
}
|
||||||
|
return "".join(
|
||||||
|
line for line_number, line in enumerate(lines, start=1) if line_number not in retired
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_frontend_runtime_matches_audited_baseline(testcase) -> None:
|
||||||
|
testcase.assertEqual(
|
||||||
|
reassembled_frontend_runtime(),
|
||||||
|
original_runtime_after_audited_retirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assert_moved_asset_matches(
|
def assert_moved_asset_matches(
|
||||||
testcase,
|
testcase,
|
||||||
original_relative: str,
|
original_relative: str,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from database import ReviewDatabase
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
FRONTEND_ROOT = APP_ROOT / "frontend"
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupContractTests(unittest.TestCase):
|
||||||
|
def test_retired_files_stay_absent(self) -> None:
|
||||||
|
self.assertFalse((APP_ROOT / "demo_data.py").exists())
|
||||||
|
self.assertFalse((FRONTEND_ROOT / "heaven-loading.js").exists())
|
||||||
|
|
||||||
|
def test_only_active_heaven_loading_asset_is_loaded(self) -> None:
|
||||||
|
html = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('src="/pages/heaven/loading-v2.js', html)
|
||||||
|
self.assertNotIn('src="/heaven-loading.js', html)
|
||||||
|
|
||||||
|
def test_audited_definition_only_functions_stay_absent(self) -> None:
|
||||||
|
runtime = "\n".join(
|
||||||
|
path.read_text(encoding="utf-8")
|
||||||
|
for path in FRONTEND_ROOT.rglob("*.js")
|
||||||
|
if "vendor" not in path.parts
|
||||||
|
)
|
||||||
|
for symbol in (
|
||||||
|
"commonReviewColumns",
|
||||||
|
"outcomeClass",
|
||||||
|
"screenerResultMatchesSelection",
|
||||||
|
"selectRegime",
|
||||||
|
"showHeartRitualCurtain",
|
||||||
|
):
|
||||||
|
with self.subTest(symbol=symbol):
|
||||||
|
self.assertNotIn(symbol, runtime)
|
||||||
|
|
||||||
|
def test_wencai_history_compatibility_is_retained(self) -> None:
|
||||||
|
for method in (
|
||||||
|
"list_wencai_saved_queries",
|
||||||
|
"save_wencai_query",
|
||||||
|
"delete_wencai_saved_query",
|
||||||
|
):
|
||||||
|
with self.subTest(method=method):
|
||||||
|
self.assertTrue(hasattr(ReviewDatabase, method))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -7,18 +7,15 @@ from backend.bootstrap.config import APP_DIR, STATIC_DIR
|
|||||||
from tests.preservation_helpers import (
|
from tests.preservation_helpers import (
|
||||||
FRONTEND_ROOT,
|
FRONTEND_ROOT,
|
||||||
ORIGINAL_STATIC,
|
ORIGINAL_STATIC,
|
||||||
|
assert_frontend_runtime_matches_audited_baseline,
|
||||||
assert_moved_asset_matches,
|
assert_moved_asset_matches,
|
||||||
assert_page_prefix_matches,
|
assert_page_prefix_matches,
|
||||||
reassembled_frontend_runtime,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FrontendPreservationSliceTests(unittest.TestCase):
|
class FrontendPreservationSliceTests(unittest.TestCase):
|
||||||
def test_split_runtime_reassembles_to_the_exact_original(self) -> None:
|
def test_split_runtime_matches_original_except_audited_retirements(self) -> None:
|
||||||
self.assertEqual(
|
assert_frontend_runtime_matches_audited_baseline(self)
|
||||||
reassembled_frontend_runtime(),
|
|
||||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_index_diff_is_limited_to_asset_relocation_and_split_loading(self) -> None:
|
def test_index_diff_is_limited_to_asset_relocation_and_split_loading(self) -> None:
|
||||||
migrated = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
|
migrated = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
|
||||||
@@ -64,7 +61,6 @@ class FrontendPreservationSliceTests(unittest.TestCase):
|
|||||||
for original, migrated in (
|
for original, migrated in (
|
||||||
("ui-core.js", "shared/ui-core.js"),
|
("ui-core.js", "shared/ui-core.js"),
|
||||||
("heaven-loading-v2.js", "pages/heaven/loading-v2.js"),
|
("heaven-loading-v2.js", "pages/heaven/loading-v2.js"),
|
||||||
("heaven-loading.js", "heaven-loading.js"),
|
|
||||||
("vendor/lucide.min.js", "vendor/lucide.min.js"),
|
("vendor/lucide.min.js", "vendor/lucide.min.js"),
|
||||||
("pages.config.js", "pages.config.js"),
|
("pages.config.js", "pages.config.js"),
|
||||||
("pages/runtime.js", "pages/runtime.js"),
|
("pages/runtime.js", "pages/runtime.js"),
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tests.preservation_helpers import (
|
from tests.preservation_helpers import (
|
||||||
ORIGINAL_STATIC,
|
assert_frontend_runtime_matches_audited_baseline,
|
||||||
assert_moved_asset_matches,
|
assert_moved_asset_matches,
|
||||||
assert_page_prefix_matches,
|
assert_page_prefix_matches,
|
||||||
reassembled_frontend_runtime,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,10 +83,7 @@ class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase):
|
|||||||
sha256(APP_ROOT / "config/api.config.json"),
|
sha256(APP_ROOT / "config/api.config.json"),
|
||||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
assert_frontend_runtime_matches_audited_baseline(self)
|
||||||
reassembled_frontend_runtime(),
|
|
||||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
|
||||||
)
|
|
||||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||||
for page in ("pages/ladder/page.js", "pages/rotation/page.js"):
|
for page in ("pages/ladder/page.js", "pages/rotation/page.js"):
|
||||||
assert_page_prefix_matches(self, page)
|
assert_page_prefix_matches(self, page)
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ from backend.data.providers import ifind_client as canonical_ifind
|
|||||||
from backend.data.providers import tushare_client as canonical_tushare
|
from backend.data.providers import tushare_client as canonical_tushare
|
||||||
from backend.features.market import charts
|
from backend.features.market import charts
|
||||||
from tests.preservation_helpers import (
|
from tests.preservation_helpers import (
|
||||||
ORIGINAL_STATIC,
|
assert_frontend_runtime_matches_audited_baseline,
|
||||||
assert_moved_asset_matches,
|
assert_moved_asset_matches,
|
||||||
reassembled_frontend_runtime,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -152,10 +151,7 @@ class MarketSliceSourceEquivalenceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_relocated_frontend_preserves_original_market_runtime_and_styles(self) -> None:
|
def test_relocated_frontend_preserves_original_market_runtime_and_styles(self) -> None:
|
||||||
self.assertEqual(
|
assert_frontend_runtime_matches_audited_baseline(self)
|
||||||
reassembled_frontend_runtime(),
|
|
||||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
|
||||||
)
|
|
||||||
for original, migrated in (
|
for original, migrated in (
|
||||||
("styles.css", "styles/styles.css"),
|
("styles.css", "styles/styles.css"),
|
||||||
("renovation.css", "styles/renovation.css"),
|
("renovation.css", "styles/renovation.css"),
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ from pathlib import Path
|
|||||||
import market_insights
|
import market_insights
|
||||||
from backend.features.market import insights as canonical_insights
|
from backend.features.market import insights as canonical_insights
|
||||||
from tests.preservation_helpers import (
|
from tests.preservation_helpers import (
|
||||||
ORIGINAL_STATIC,
|
assert_frontend_runtime_matches_audited_baseline,
|
||||||
assert_moved_asset_matches,
|
assert_moved_asset_matches,
|
||||||
assert_page_prefix_matches,
|
assert_page_prefix_matches,
|
||||||
reassembled_frontend_runtime,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -184,10 +183,7 @@ class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase):
|
|||||||
sha256(APP_ROOT / "config/api.config.json"),
|
sha256(APP_ROOT / "config/api.config.json"),
|
||||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
assert_frontend_runtime_matches_audited_baseline(self)
|
||||||
reassembled_frontend_runtime(),
|
|
||||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
|
||||||
)
|
|
||||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||||
for page in (
|
for page in (
|
||||||
"pages/auction/page.js",
|
"pages/auction/page.js",
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import strategy_tracking
|
|||||||
from backend.features.screener import compiler, engine, strategies, tracking
|
from backend.features.screener import compiler, engine, strategies, tracking
|
||||||
from backend.features.screener import service as screener_service
|
from backend.features.screener import service as screener_service
|
||||||
from tests.preservation_helpers import (
|
from tests.preservation_helpers import (
|
||||||
ORIGINAL_STATIC,
|
assert_frontend_runtime_matches_audited_baseline,
|
||||||
assert_moved_asset_matches,
|
assert_moved_asset_matches,
|
||||||
assert_page_prefix_matches,
|
assert_page_prefix_matches,
|
||||||
reassembled_frontend_runtime,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,10 +187,7 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_screener_frontend_assets_are_unchanged(self) -> None:
|
def test_screener_frontend_assets_are_unchanged(self) -> None:
|
||||||
self.assertEqual(
|
assert_frontend_runtime_matches_audited_baseline(self)
|
||||||
reassembled_frontend_runtime(),
|
|
||||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
|
||||||
)
|
|
||||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||||
assert_page_prefix_matches(self, "pages/screener/page.js")
|
assert_page_prefix_matches(self, "pages/screener/page.js")
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ from pathlib import Path
|
|||||||
import sentiment_engine
|
import sentiment_engine
|
||||||
from backend.features.sentiment import engine as canonical_engine
|
from backend.features.sentiment import engine as canonical_engine
|
||||||
from tests.preservation_helpers import (
|
from tests.preservation_helpers import (
|
||||||
ORIGINAL_STATIC,
|
assert_frontend_runtime_matches_audited_baseline,
|
||||||
assert_moved_asset_matches,
|
assert_moved_asset_matches,
|
||||||
assert_page_prefix_matches,
|
assert_page_prefix_matches,
|
||||||
reassembled_frontend_runtime,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -105,10 +104,7 @@ class SentimentPoolSliceSourceEquivalenceTests(unittest.TestCase):
|
|||||||
sha256(APP_ROOT / "config/api.config.json"),
|
sha256(APP_ROOT / "config/api.config.json"),
|
||||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
assert_frontend_runtime_matches_audited_baseline(self)
|
||||||
reassembled_frontend_runtime(),
|
|
||||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
|
|
||||||
)
|
|
||||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||||
for page in ("pages/sentiment/page.js", "pages/pools/page.js"):
|
for page in ("pages/sentiment/page.js", "pages/pools/page.js"):
|
||||||
assert_page_prefix_matches(self, page)
|
assert_page_prefix_matches(self, page)
|
||||||
|
|||||||
Reference in New Issue
Block a user