# aardio 范例: canvas - 桌面圆形时钟



```aardio
//canvas - 桌面圆形时钟
//canvas 转为透明分层窗口（orphanWindow(true)）后可以画任意形状的窗口
import win.ui;
import time.lunar;
/*DSG{{*/
var winform = win.form(text="桌面圆形时钟";left=-20;top=-40;right=276;bottom=239;border="none";topmost=1)
winform.add(
clock={cls="canvas";left=0;top=0;right=277;bottom=240;anchors={left=1;top=1;right=1;bottom=1};z=1}
)
/*}}*/

/*
动静分层：
- 表盘是静态层，按尺寸与 DPI 缓存成位图，每帧整块复制。
- 指针与日期是动态层，每帧重画。

读时依赖的是表盘三分区而不是简单线段：
- 时针止于小时数字内沿，尺寸最短、面积最大。
- 分针穿过数字环，止于数字与分钟刻度之间，不接触刻度。
- 秒针最细最长，扫过刻度并保留配重尾。
*/

// 按暖白表盘半径统一计算边框、刻度、数字环与三针长度，避免表盘层和指针层各自约定。
var clockLayout = function(width,height,dpi){
	var centerX = width/2;
	var centerY = height/2;
	var outerRadius = math.min(centerX,centerY)-2*dpi;
	var rimRadius = outerRadius-1.4*dpi;
	var faceRadius = outerRadius-4.4*dpi;
	var tickOuter = faceRadius-6.2*dpi;
	var tickMinuteLen = 4.4*dpi;
	var numberRadius = faceRadius*0.72;

	// 分针穿过数字环，但停在数字与分钟刻度之间，不接触刻度。
	var tickInner = tickOuter-tickMinuteLen;
	var minuteLen = numberRadius+(tickInner-numberRadius)*0.55;

	return {
		centerX = centerX;
		centerY = centerY;
		outerRadius = outerRadius;
		rimRadius = rimRadius;
		faceRadius = faceRadius;
		tickOuter = tickOuter;
		tickMinuteLen = tickMinuteLen;
		tickHourLen = 8.6*dpi;
		tickQuarterLen = 11.2*dpi;
		numberRadius = numberRadius;
		hourLen = faceRadius*0.56;
		minuteLen = minuteLen;
		secondLen = tickOuter+0.6*dpi;
		secondTail = faceRadius*0.17;
		hourHalfW = 3.85*dpi;
		minuteHalfW = 2.45*dpi;
		secondWidth = 1.05*dpi;
		handTail = 3.6*dpi;
		hubOuter = 5.8*dpi;
		hubAccent = 3.5*dpi;
		hubPin = 1.55*dpi;
		weightR = 3.3*dpi;
	}
}

// 将 12 小时周期内的秒数转为画板角度。时针会把分、秒投影进小时区间。
var clockAngles = function(clockSeconds){
	return {
		hour = clockSeconds/3600*math.pi/6-math.pi/2;
		minute = (clockSeconds%3600)/60*math.pi/30-math.pi/2;
		second = (clockSeconds%60)*math.pi/30-math.pi/2;
	}
}

// 剜削式指针：腰部最宽，针尖收窄为尖点，便于对准刻度。
var fillTaperedHand = function(graphics,brush,cx,cy,angle,length,halfWidth,tail){
	var ca,sa = math.cos(angle),math.sin(angle);
	var nx,ny = -sa,ca;
	// 先保持稳定宽度，只在最后一段收成尖点，避免分针大半段细成像秒针。
	var waist = length*0.12;
	var neck = length*0.82;
	var tailW = halfWidth*0.78;
	var waistW = halfWidth;
	var neckW = halfWidth*0.55;

	graphics.fillPolygon(brush,0/*_FillModeAlternate*/,[ 
		[cx-tail*ca+nx*tailW,cy-tail*sa+ny*tailW],
		[cx+waist*ca+nx*waistW,cy+waist*sa+ny*waistW],
		[cx+neck*ca+nx*neckW,cy+neck*sa+ny*neckW],
		[cx+length*ca,cy+length*sa],
		[cx+neck*ca-nx*neckW,cy+neck*sa-ny*neckW],
		[cx+waist*ca-nx*waistW,cy+waist*sa-ny*waistW],
		[cx-tail*ca-nx*tailW,cy-tail*sa-ny*tailW]
	]);
}

// -------------------- 静态表盘层 --------------------
var drawFace = function(graphics,layout,dpi,numberFont){
	/*
	边框使用三层实心圆构成，避免粗描边在圆周两侧裁切：
	深青灰外框负责收口，暖金内圈增加层次，最内层是暖白表盘。
	*/
	var bezelBrush = gdip.solidBrush(0xFF26363D);
	var rimBrush = gdip.solidBrush(0xFFC7A45B);
	var faceBrush = gdip.solidBrush(0xFFF7F4EB);

	graphics.fillCircle(bezelBrush,layout.centerX,layout.centerY,layout.outerRadius);
	graphics.fillCircle(rimBrush,layout.centerX,layout.centerY,layout.rimRadius);
	graphics.fillCircle(faceBrush,layout.centerX,layout.centerY,layout.faceRadius);

	bezelBrush.delete();
	rimBrush.delete();
	faceBrush.delete();

	var minuteMarkPen = gdip.pen(0xFF9AA1A3,0.75*dpi);
	var hourMarkPen = gdip.pen(0xFF34444B,1.85*dpi);
	var quarterMarkPen = gdip.pen(0xFFAD7B2F,2.25*dpi);
	for(i,pen in [minuteMarkPen,hourMarkPen,quarterMarkPen]){
		pen.startCap = 2/*_LineCapRound*/;
		pen.endCap = 2/*_LineCapRound*/;
	}

	for(i=0;59;1){
		var angle = i*math.pi/30-math.pi/2;
		var tickLength = layout.tickMinuteLen;
		var markPen = minuteMarkPen;

		if(i%15==0){
			tickLength = layout.tickQuarterLen;
			markPen = quarterMarkPen;
		}
		elseif(i%5==0){
			tickLength = layout.tickHourLen;
			markPen = hourMarkPen;
		}

		graphics.drawLine(markPen,
			layout.centerX+(layout.tickOuter-tickLength)*math.cos(angle),
			layout.centerY+(layout.tickOuter-tickLength)*math.sin(angle),
			layout.centerX+layout.tickOuter*math.cos(angle),
			layout.centerY+layout.tickOuter*math.sin(angle)
		);
	}

	minuteMarkPen.delete();
	hourMarkPen.delete();
	quarterMarkPen.delete();

	var numberBrush = gdip.solidBrush(0xFF34444B);
	var strFormat = gdip.stringFormat();
	strFormat.align = 1/*_StringAlignmentCenter*/;
	strFormat.lineAlign = 1/*_StringAlignmentCenter*/;
	var boxW = layout.faceRadius*0.32;
	var boxH = layout.faceRadius*0.20;

	for(h=1;12;1){
		var hourAngle = (h%12)*math.pi/6-math.pi/2;
		var textX = layout.centerX+layout.numberRadius*math.cos(hourAngle);
		var textY = layout.centerY+layout.numberRadius*math.sin(hourAngle);
		graphics.drawString(tostring(h),numberFont,
			::RECTF(textX-boxW/2,textY-boxH/2,boxW,boxH),
			strFormat,numberBrush
		);
	}

	numberBrush.delete();
	strFormat.delete();
}

// 表盘按像素尺寸与 DPI 缓存为 PARGB 位图（canvas 画布的格式，复制最快），尺寸或 DPI 改变时重建。
var faceLayer,faceKey;
var ensureFaceLayer = function(width,height,dpi){
	var key = width ++ "x" ++ height ++ "@" ++ dpi;
	if(faceLayer && faceKey === key) return faceLayer;
	if(faceLayer) faceLayer.delete();

	// 字体由调用方自己准备：数字用半粗体，字号随 DPI 缩放。
	var numberFont = gdip.font("Segoe UI Semibold",16*dpi,0/*_FontStyleRegular*/,2/*_UnitPixel*/)
		: gdip.font("Segoe UI",16*dpi,1/*_FontStyleBold*/,2/*_UnitPixel*/);

	faceLayer = gdip.bitmap(width,height,0xE200B/*_GdipPixelFormat32bppPARGB*/);
	var graphics = faceLayer.getGraphics();
	graphics.smoothingMode = 4/*_SmoothingModeAntiAlias*/;
	graphics.textRenderingHint = 4/*_TextRenderingHintAntiAlias*/;
	drawFace(graphics,clockLayout(width,height,dpi),dpi,numberFont);
	graphics.delete();
	numberFont.delete();

	faceKey = key;
	return faceLayer;
}

// 分层窗口模式下画布底色是全透明的，表盘圆形之外保持透明。
winform.clock.onDrawBackground = function(graphics,rc){
	var dpiX,dpiY = owner.dpiScale(1,1);
	var layer = ensureFaceLayer(rc.width,rc.height,math.min(dpiX,dpiY));

	// 表盘缓存与画布一样大，用 SourceCopy 整块复制（连同透明区域），比默认的混合模式快得多。
	graphics.compositingMode = 1/*_CompositingModeSourceCopy*/;
	graphics.drawImageRect(layer,0,0,rc.width,rc.height);
	graphics.compositingMode = 0/*_CompositingModeSourceOver*/;
}

// -------------------- 动态指针层 --------------------
var hourHandBrush,minuteHandBrush,secondHandPen;
var centerOuterBrush,centerAccentBrush,centerPinBrush;
var calendarFont,calendarDateBrush,calendarLunarBrush,calendarFormat;
var handDpiX,handDpiY = 0,0;

var releaseHandResources = function(){
	for(i,obj in [hourHandBrush,minuteHandBrush,secondHandPen,centerOuterBrush,centerAccentBrush,
		centerPinBrush,calendarFont,calendarDateBrush,calendarLunarBrush,calendarFormat]){
		obj.delete();
	}

	hourHandBrush = null;
	minuteHandBrush = null;
	secondHandPen = null;
	centerOuterBrush = null;
	centerAccentBrush = null;
	centerPinBrush = null;
	calendarFont = null;
	calendarDateBrush = null;
	calendarLunarBrush = null;
	calendarFormat = null;
}

var ensureHandResources = function(dpiX,dpiY){
	if(hourHandBrush
		&& math.abs(handDpiX-dpiX)<0.001
		&& math.abs(handDpiY-dpiY)<0.001) return;

	releaseHandResources();

	hourHandBrush = gdip.solidBrush(0xFF24343B);
	minuteHandBrush = gdip.solidBrush(0xFF3A4A51);
	secondHandPen = gdip.pen(0xFFDF5145,1.02*math.min(dpiX,dpiY));
	secondHandPen.startCap = 2/*_LineCapRound*/;
	secondHandPen.endCap = 2/*_LineCapRound*/;

	centerOuterBrush = gdip.solidBrush(0xFF26363D);
	centerAccentBrush = gdip.solidBrush(0xFFC7A45B);
	centerPinBrush = gdip.solidBrush(0xFFDF5145);

	// 日期文字比表盘数字小一档，并使用半透明深青灰色，清楚但不抢主视觉。
	calendarFont = gdip.font("Microsoft YaHei UI",10*math.min(dpiX,dpiY),0/*_FontStyleRegular*/,2/*_UnitPixel*/);
	calendarDateBrush = gdip.solidBrush(0xE0445258);
	calendarLunarBrush = gdip.solidBrush(0xE0445258);
	calendarFormat = gdip.stringFormat();
	calendarFormat.align = 1/*_StringAlignmentCenter*/;
	calendarFormat.lineAlign = 1/*_StringAlignmentCenter*/;

	handDpiX,handDpiY = dpiX,dpiY;
}

// 将系统时间转换为 12 小时周期内的秒数。
var readSystemClock = function(){
	var now = time();
	return (now.hour%12)*3600+now.minute*60+now.second+now.milliseconds/1000;
}

var clockSeconds = readSystemClock();
var clockSyncElapsed = 0;

// 公历、星期和农历只每 5 秒读取一次，绘制时直接使用缓存文本。
var calendarDateText,calendarLunarText;
var updateCalendarText = function(){
	var lunar = time.lunar();
	calendarDateText = tostring(lunar,"%Y/%m/%d %a");
	calendarLunarText = tostring(lunar,"%Lm %Ld");
}
updateCalendarText();

winform.clock.onDrawForeground = function(graphics,rc){
	var dpiX,dpiY = owner.dpiScale(1,1);
	var dpi = math.min(dpiX,dpiY);
	ensureHandResources(dpiX,dpiY);

	var layout = clockLayout(rc.width,rc.height,dpi);
	var angles = clockAngles(clockSeconds);

	// 日期放在表盘中下部，先画日期，再由指针自然覆盖。
	graphics.textRenderingHint = 4/*_TextRenderingHintAntiAlias*/;
	var calendarBoxW = layout.faceRadius*1.25;
	var calendarBoxH = 13*dpi;
	var calendarDateY = layout.centerY+layout.faceRadius*0.33;
	var calendarLunarY = calendarDateY+15*dpi;
	graphics.drawString(calendarDateText,calendarFont,
		::RECTF(layout.centerX-calendarBoxW/2,calendarDateY-calendarBoxH/2,calendarBoxW,calendarBoxH),
		calendarFormat,calendarDateBrush
	);
	graphics.drawString(calendarLunarText,calendarFont,
		::RECTF(layout.centerX-calendarBoxW/2,calendarLunarY-calendarBoxH/2,calendarBoxW,calendarBoxH),
		calendarFormat,calendarLunarBrush
	);

	fillTaperedHand(graphics,hourHandBrush,layout.centerX,layout.centerY,
		angles.hour,layout.hourLen,layout.hourHalfW,layout.handTail);
	fillTaperedHand(graphics,minuteHandBrush,layout.centerX,layout.centerY,
		angles.minute,layout.minuteLen,layout.minuteHalfW,layout.handTail);

	var secondTipX = layout.centerX+layout.secondLen*math.cos(angles.second);
	var secondTipY = layout.centerY+layout.secondLen*math.sin(angles.second);
	var secondTailX = layout.centerX-layout.secondTail*math.cos(angles.second);
	var secondTailY = layout.centerY-layout.secondTail*math.sin(angles.second);
	graphics.drawLine(secondHandPen,secondTailX,secondTailY,secondTipX,secondTipY);
	graphics.fillCircle(centerPinBrush,secondTailX,secondTailY,layout.weightR);

	graphics.fillCircle(centerOuterBrush,layout.centerX,layout.centerY,layout.hubOuter);
	graphics.fillCircle(centerAccentBrush,layout.centerX,layout.centerY,layout.hubAccent);
	graphics.fillCircle(centerPinBrush,layout.centerX,layout.centerY,layout.hubPin);
}

/*
onAnimation 返回后画布自动重绘。clockSeconds 按实际 deltaMs 连续推进，
每 5 秒重新读取系统时间，避免长期累计误差并响应系统校时。
*/
winform.clock.onAnimation = function(prevState,beginning,change,timestamp,duration,deltaMs){
	if(deltaMs>0){
		clockSeconds = (clockSeconds+deltaMs/1000)%43200;
		clockSyncElapsed += deltaMs;

		if(clockSyncElapsed>=5000){
			clockSeconds = readSystemClock();
			clockSyncElapsed = 0;
			updateCalendarText();
		}
	}

	// 仅全等于 null（或无返回值）才停止动画。
	return true;
}

// 画布转换为透明分层窗口，悬浮在原位置并跟随父窗体移动。
winform.clock.orphanWindow(true);

// 父窗体转为完全透明的分层窗口，只负责定位与拖动。
import win.ui.layered;
win.ui.layered(winform);

// 允许从表盘上拖动整个时钟。
winform.clock.onMouseDown = function(wParam,lParam){
	winform.hitCaption();
}

// 右键菜单。
import win.ui.menu;
winform.clock.onContextMenu = function(x,y){
	var menu = win.ui.popmenu(winform);
	menu.add("退出",function(){
		winform.close();
	});
	menu.popup();
}

winform.onDestroy = function(){
	releaseHandResources();
	if(faceLayer) faceLayer.delete();
}

winform.show();

// 约 30 FPS（canvas 按显示器刷新周期的整数倍降频）。刷新频率只影响平滑度，走时由 deltaMs 决定。
winform.clock.startAnimation(33);

win.loopMessage();

```

