인생 100년 달력
설명
100년 인생을 5,200칸(1주 = 1칸)으로 펼쳐 놓고, 지금 내가 어디쯤 와 있는지와 남은 시간을 보여 주는 도구입니다.
태그
calender
작성자
메이커-4b22 | MDS 1.0
100년 인생을 5,200칸(1주 = 1칸)으로 펼쳐 놓고, 지금 내가 어디쯤 와 있는지와 남은 시간을 보여 주는 도구입니다.
README
100년 인생을 5,200칸(1주 = 1칸)으로 펼쳐 놓고, 지금 내가 어디쯤 와 있는지와 남은 시간을 보여 주는 도구입니다.
calender
메이커-4b22 | MDS 1.0
# -*- coding: utf-8 -*-
# 인생 100년 달력 v3 — 100년 = 5,200주를 한 장에
# 인생의 사계절 달력 · 인생 시계 · 살아 있는 카운터 · 남은 시간의 쓰임
# 부모님과 남은 시간 · 부모님과 남은 여행(계획 D-day) · 남은 것들 · 이정표
# 배경: 지금 내 계절에 맞는 움직이는 배경 (봄 벚꽃잎 · 여름 반딧불이 · 가을 낙엽 · 겨울 눈)
# 조작
# 창 : 화면 크기에 맞게 최대화로 열림 (제목줄 최대화 버튼 사용 가능, 크기에 맞춰 글자·배치 자동 조정)
# 슬라이더 : 수면 · 일 · 은퇴 · 스마트폰 · 시간 여행 · 부모님 만남 주기 · 만날 때 시간(제한 없음, 숫자 직접 입력)
# · 부모님 기준 나이 · 부모님과 여행 주기 · 여행 일정
# 달력에 마우스 : 그 주가 언제인지, 그때 나와 부모님이 몇 살인지, 기념일·여행
# 달력 클릭 : 그 주로 이동 (슬라이더를 움직이면 오늘로 복귀)
# 시계 클릭 : 인생 재생 — 탄생부터 100세까지, 배경 계절도 함께 바뀜 (다시 클릭하면 정지)
import math
import datetime as _dt
from matplotlib.patches import FancyBboxPatch, Wedge, Circle, Rectangle
BG = '#0E1325'
BG_GRID = '#0F1427'
INK = '#E8ECF4'
SUB = '#8A93A8'
GOLD = '#FFD166'
PINK = '#FF7A9A'
MINT = '#5EEAD4'
CARD = '#172038'
SEASONS = [
(0, 25, '봄', '자라는 시간', '#7BD389'),
(25, 50, '여름', '뜨거운 시간', '#4FC3F7'),
(50, 75, '가을', '거두는 시간', '#FFB74D'),
(75, 100, '겨울', '돌아보는 시간', '#B39DDB'),
]
USE_ORDER = ['수면', '일', '스마트폰', '생활', '부모님과', '내 시간']
USE_COLORS = {'수면': '#3B4A7A', '일': '#5C6B8A', '스마트폰': '#8E5B7A', '생활': '#4A5A5A', '부모님과': PINK, '내 시간': GOLD}
CELL, GAP, DGAP = 4, 1, 3
YEARS, WEEKS = 100, 52
TOGETHER_H_PER_DAY = 4.0
TRIP_H_PER_DAY = 12.0
LIFE_H_PER_DAY = 2.5
P_WORKDAYS = 250.0 / 365.0
BASE_W, BASE_H = 11.0, 8.0
UI = {'s': 1.0}
FPS_MS = 60
def fs(x: float) -> float:
return x * UI['s']
def hex_rgb(h: str) -> np.ndarray:
h = h.lstrip('#')
return np.array([int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)])
def season_of(age: int) -> tuple:
for s in SEASONS:
if s[0] <= age < s[1]:
return s
return SEASONS[-1]
def add_years(d: _dt.date, n: int) -> _dt.date:
try:
return d.replace(year=d.year + n)
except ValueError:
return d.replace(year=d.year + n, day=28)
def parse_date(s: str) -> _dt.date:
t = str(s).strip().replace('.', '-').replace('/', '-').replace(' ', '')
if len(t) == 8 and t.isdigit():
t = t[:4] + '-' + t[4:6] + '-' + t[6:]
y, m, d = [int(v) for v in t.split('-')[:3]]
return _dt.date(y, m, d)
def split_entries(s: str) -> list:
if not s:
return []
return [c.strip() for c in str(s).replace('\n', ';').replace(',', ';').split(';') if c.strip()]
def parse_events(s: str) -> list:
out = []
for chunk in split_entries(s):
parts = chunk.split(None, 1)
try:
out.append((parse_date(parts[0]), parts[1].strip() if len(parts) > 1 else '기념일'))
except Exception:
continue
return out
def parse_trips(s: str) -> list:
out = []
for chunk in split_entries(s):
parts = chunk.split()
try:
d = parse_date(parts[0])
except Exception:
continue
rest = parts[1:]
days = 1
if rest:
tail = rest[-1].replace('일', '').replace('박', '')
if tail.isdigit():
days = max(1, int(tail))
rest = rest[:-1]
out.append((d, ' '.join(rest) if rest else '여행', days))
return sorted(out, key=lambda t: t[0])
def week_pos(birth: _dt.date, d: _dt.date) -> tuple:
age = d.year - birth.year - ((d.month, d.day) < (birth.month, birth.day))
age = max(0, min(YEARS - 1, age))
col = min(WEEKS - 1, max(0, (d - add_years(birth, age)).days // 7))
return age, col
def cell_date(birth: _dt.date, rc: tuple) -> _dt.date:
return add_years(birth, rc[0]) + _dt.timedelta(days=7 * rc[1])
def cell_xy(row: int, col: int) -> tuple:
x = col * (CELL + GAP) + CELL / 2.0
y = row * (CELL + GAP) + (row // 10) * DGAP + CELL / 2.0
return x, y
def xy_cell(x: float, y: float) -> tuple:
if x is None or y is None or x < 0 or x >= WEEKS * (CELL + GAP):
return None
col = int(x // (CELL + GAP))
for r in range(YEARS):
y0 = r * (CELL + GAP) + (r // 10) * DGAP
if y0 - 0.5 <= y < y0 + CELL + GAP:
return r, col
return None
def grid_layers() -> tuple:
H = YEARS * (CELL + GAP) + 9 * DGAP
W = WEEKS * (CELL + GAP)
bg = hex_rgb(BG_GRID)
past = np.tile(bg, (H, W, 1))
future = past.copy()
idx = np.full((H, W), -1, dtype=int)
for r in range(YEARS):
c = hex_rgb(season_of(r)[4])
y0 = r * (CELL + GAP) + (r // 10) * DGAP
for k in range(WEEKS):
x0 = k * (CELL + GAP)
past[y0:y0 + CELL, x0:x0 + CELL] = c
future[y0:y0 + CELL, x0:x0 + CELL] = hex_rgb(BG) * 0.80 + c * 0.20
idx[y0:y0 + CELL, x0:x0 + CELL] = r * WEEKS + k
return past, future, idx
def milestones(birth: _dt.date, events: list) -> list:
base = [
(birth + _dt.timedelta(days=10000), '1만 일', 'm'),
(birth + _dt.timedelta(seconds=10 ** 9), '10억 초', 'm'),
(birth + _dt.timedelta(days=20000), '2만 일', 'm'),
(add_years(birth, 50), '인생의 정오 50세', 'm'),
(birth + _dt.timedelta(days=30000), '3만 일', 'm'),
(add_years(birth, 100), '100세', 'm'),
]
base += [(d, name, 'e') for d, name in events if birth <= d < add_years(birth, 100)]
return sorted(base, key=lambda t: t[0])
def kr_num(v: float) -> str:
if v >= 1e8:
return '%.1f억' % (v / 1e8)
if v >= 1e4:
return '%.1f만' % (v / 1e4)
return '{:,.0f}'.format(v)
def dday(n: int, long: bool = False) -> str:
if n == 0:
return 'D-DAY'
if n < 0:
return '지남'
return 'D-%s' % '{:,}'.format(n) + (' (%.1f년)' % (n / 365.25) if long and n > 365 else '')
def life_clock(frac: float) -> str:
mins = int(round(frac * 1440)) % 1440 if frac < 1 else 1439
h, m = divmod(mins, 60)
return '%s %d:%02d' % ('오전' if h < 12 else '오후', (h % 12) or 12, m)
def count_april(start: _dt.date, end: _dt.date) -> int:
return sum(1 for y in range(start.year, end.year + 1) if start <= _dt.date(y, 4, 1) < end)
def parents_at(prof: dict, when: _dt.date) -> list:
dy = (when - prof['today']).days / 365.25
return [(nm, a + dy) for nm, a in prof['parents']]
def parent_time(p: dict, prof: dict, now: _dt.date) -> dict:
birth = prof['birth']
my_age = (now - birth).days / 365.25
target = float(p['부모님 기준 나이'])
cyc = max(1.0, float(p['부모님 만남 주기 (주)']))
hrs = float(p['만날 때 함께하는 시간'])
t_cyc = max(1.0, float(p['부모님과 여행 주기 (개월)']))
t_days = float(p['여행 일정 (일)'])
ps = parents_at(prof, now)
if not ps:
return {}
rem = {nm: max(0.0, target - a) for nm, a in ps}
rem_max = max(rem.values())
rem_min = min(rem.values())
indep = prof['indep']
living = indep <= 0 or indep > my_age
if living:
cut = rem_max if indep <= 0 else min(rem_max, indep - my_age)
meet_h = cut * 365.25 * TOGETHER_H_PER_DAY + max(0.0, rem_max - cut) * 52.0 / cyc * hrs
past_h = my_age * 365.25 * TOGETHER_H_PER_DAY
else:
meet_h = rem_max * 52.0 / cyc * hrs
past_h = indep * 365.25 * TOGETHER_H_PER_DAY + (my_age - indep) * 52.0 / cyc * hrs
trips = rem_max * 12.0 / t_cyc
trip_days = trips * t_days
fut_h = meet_h + trip_days * TRIP_H_PER_DAY
return {
'ages': ps, 'rem': rem, 'rem_max': rem_max,
'visits': {nm: r * 52.0 / cyc for nm, r in rem.items()},
'v_any': rem_max * 52.0 / cyc, 'v_both': rem_min * 52.0 / cyc,
'meet_h': meet_h, 'fut_h': fut_h, 'used': past_h / max(1e-9, past_h + fut_h),
'trips': trips, 'trips_both': rem_min * 12.0 / t_cyc, 'trip_days': trip_days,
'holidays': int(round(2 * rem_max)), 'cyc': cyc, 'hrs': hrs, 't_cyc': t_cyc, 't_days': t_days,
'target': target, 'living': living,
}
def time_budget(p: dict, now: _dt.date, birth: _dt.date, pt: dict) -> dict:
d100 = add_years(birth, 100)
rem_days = max(0, (d100 - now).days)
ret = add_years(birth, int(p['은퇴 나이']))
hrs = {
'수면': p['하루 수면 (시간)'] * rem_days,
'일': p['하루 일 (시간, 평일)'] * max(0, (ret - now).days) * P_WORKDAYS,
'스마트폰': p['하루 스마트폰 (시간)'] * rem_days,
'생활': LIFE_H_PER_DAY * rem_days,
}
free = max(0.0, rem_days * 24.0 - sum(hrs.values()))
hrs['부모님과'] = min(free, pt.get('fut_h', 0.0))
hrs['내 시간'] = free - hrs['부모님과']
return {k: v / 8766.0 for k, v in hrs.items()}
def card(ax, x: float, y: float, w: float, h: float, big: str, small: str, color: str = INK, size: float = 11) -> None:
ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle='round,pad=0,rounding_size=0.008',
mutation_aspect=1.5, fc=CARD, ec='none', transform=ax.transAxes))
ax.text(x + w / 2, y + h * 0.62, big, ha='center', va='center', fontsize=fs(size), fontweight='bold', color=color, transform=ax.transAxes)
ax.text(x + w / 2, y + h * 0.24, small, ha='center', va='center', fontsize=fs(6.8), color=SUB, transform=ax.transAxes)
return None
def draw_grid(gax, prof: dict, now: _dt.date, pt: dict, art: dict) -> None:
birth = prof['birth']
past, future, idx = prof['layers']
gax.cla()
gax.set_facecolor(BG)
rc = week_pos(birth, now)
ni = rc[0] * WEEKS + rc[1]
img = np.where((idx < ni)[:, :, None], past, future)
img[idx == ni] = 1.0
gax.imshow(img, interpolation='nearest', origin='upper')
H, W = img.shape[:2]
gax.set_xlim(-34, W + 40)
gax.set_ylim(H + 26, -14)
gax.set_axis_off()
k2 = UI['s'] ** 2
for dec in range(0, 100, 10):
gax.text(-4, cell_xy(dec, 0)[1], '%d' % dec, ha='right', va='center', fontsize=fs(7), color=SUB)
for a0, a1, sname, _, col in SEASONS:
ym = (cell_xy(a0, 0)[1] + cell_xy(a1 - 1, 0)[1]) / 2
gax.text(-22, ym, sname, ha='center', va='center', fontsize=fs(10), color=col, fontweight='bold', rotation=90)
for d, label, kind in prof['ms']:
x, y = cell_xy(*week_pos(birth, d))
if kind == 'm':
gax.scatter([x], [y], marker='*', s=46 * k2, color='#FFFFFF' if d <= now else GOLD, edgecolors=BG, linewidths=0.4, zorder=5)
else:
gax.scatter([x], [y], marker='D', s=16 * k2, color=PINK, edgecolors=BG, linewidths=0.4, zorder=5)
for d, label, days in prof['trips']:
if birth <= d < add_years(birth, 100):
x, y = cell_xy(*week_pos(birth, d))
gax.scatter([x], [y], marker='^', s=22 * k2, color=MINT if d >= now else '#FFFFFF', edgecolors=BG, linewidths=0.4, zorder=5)
if pt and pt['rem_max'] > 0:
r_end = min(YEARS - 1, int(rc[0] + pt['rem_max']))
y0, y1 = cell_xy(rc[0], 0)[1], cell_xy(r_end, 0)[1] + CELL / 2
gax.plot([W + 3, W + 3], [y0, y1], color=PINK, lw=2.2 * UI['s'], solid_capstyle='round', alpha=0.9)
gax.plot([W + 1, W + 3], [y1, y1], color=PINK, lw=2.2 * UI['s'], alpha=0.9)
if y1 - y0 > 40:
gax.text(W + 15, (y0 + y1) / 2 + 10, '부모님과 %.0f년' % pt['rem_max'], rotation=270,
ha='center', va='center', fontsize=fs(7.5), color=PINK)
x, y = cell_xy(*rc)
art['glow'] = gax.scatter([x], [y], marker='s', s=90 * k2, facecolors='none', edgecolors=GOLD, linewidths=1.4, zorder=6)
gax.text(W + 7, y, '◀ 지금' if now == prof['today'] else '◀ 여기', ha='left', va='center', fontsize=fs(8), color=GOLD, fontweight='bold')
gax.text(W / 2, -8, '%s의 100년 · 5,200주' % prof['name'], ha='center', va='center', fontsize=fs(12), color=INK, fontweight='bold')
art['hover_mark'] = gax.scatter([np.nan], [np.nan], marker='s', s=60 * k2, facecolors='none', edgecolors='#FFFFFF', linewidths=1.2, zorder=7)
art['hover_txt'] = gax.text(W / 2, H + 9, '달력에 마우스를 올려 보세요 · 클릭하면 그 주로 이동', ha='center', va='center', fontsize=fs(7.5), color=SUB)
art['hover_txt2'] = gax.text(W / 2, H + 20, '', ha='center', va='center', fontsize=fs(7.5), color=PINK)
return None
def draw_clock(cax, frac: float, playing: bool) -> None:
cax.cla()
cax.set_facecolor('none')
cax.set_xlim(-1.25, 1.25)
cax.set_ylim(-1.3, 1.25)
cax.set_aspect('equal')
cax.set_axis_off()
for h0, h1, col in [(0, 6, '#1B2547'), (6, 18, '#2C3E73'), (18, 24, '#1B2547')]:
cax.add_patch(Wedge((0, 0), 1.0, 90 - h1 * 15, 90 - h0 * 15, width=0.22, color=col))
if frac > 0:
cax.add_patch(Wedge((0, 0), 1.0, 90 - frac * 360, 90, width=0.22, color=GOLD, alpha=0.85))
cax.add_patch(Circle((0, 0), 0.74, color=CARD))
for h in range(0, 24, 3):
a = np.radians(90 - h * 15)
cax.plot([0.62 * np.cos(a), 0.70 * np.cos(a)], [0.62 * np.sin(a), 0.70 * np.sin(a)], color=SUB, lw=1)
a = np.radians(90 - frac * 360)
cax.plot([0, 0.60 * np.cos(a)], [0, 0.60 * np.sin(a)], color=GOLD, lw=2.4 * UI['s'], solid_capstyle='round')
cax.add_patch(Circle((0, 0), 0.06, color=GOLD))
cax.text(0, 1.13, '탄생', ha='center', va='center', fontsize=fs(6.3), color=SUB)
cax.text(0, -1.17, '■ 정지' if playing else '▶ 클릭: 인생 재생', ha='center', va='center', fontsize=fs(6.3),
color=GOLD if playing else SUB)
return None
def draw_budget(bax, yrs: dict) -> None:
bax.cla()
bax.set_facecolor('none')
total = sum(yrs.values())
left = 0.0
for k in USE_ORDER:
v = yrs.get(k, 0)
if v <= 0:
continue
bax.barh([0], [v], left=left, color=USE_COLORS[k], height=0.84, edgecolor=BG, linewidth=1.2)
if total > 0 and v / total > 0.06:
dark = k in ('내 시간', '부모님과')
bax.text(left + v / 2, 0, '%s %.1f년' % (k, v), ha='center', va='center', fontsize=fs(7.4),
color=BG if dark else INK, fontweight='bold' if dark else 'normal')
left += v
bax.set_xlim(0, max(total, 1e-6))
bax.set_ylim(-0.5, 0.5)
bax.set_axis_off()
return None
def dot_field(axx, n_any: int, n_both: int, cols: int, color: str, marker: str, size: float, min_rows: int) -> None:
axx.cla()
axx.set_facecolor('none')
axx.set_axis_off()
if n_any > 0:
i = np.arange(n_any)
rgba = np.zeros((n_any, 4))
rgba[:, :3] = hex_rgb(color)
rgba[:, 3] = np.where(i < n_both, 1.0, 0.40)
axx.scatter(i % cols, -(i // cols), s=size * UI['s'] ** 2, c=rgba, marker=marker, linewidths=0)
rows = max(min_rows, int(math.ceil(max(n_any, 1) / cols)))
axx.set_xlim(-1, cols)
axx.set_ylim(-rows + 0.3, 0.7)
return None
def draw_parents(ax, pax, pt: dict, X: float) -> None:
ax.text(X, 0.600, '부모님과 남은 시간', fontsize=fs(10), color=PINK, fontweight='bold', va='center')
if not pt or pt['rem_max'] <= 0:
msg = ('부모님 나이를 입력하면\n남은 만남과 함께할 시간을\n보여 줍니다.' if not pt else
'부모님 기준 나이(%.0f세)를 지난 시점이에요.\n슬라이더에서 기준 나이를 조정해 보세요.' % pt['target'])
ax.text(X, 0.50, msg, fontsize=fs(8.3), color=SUB, va='center', linespacing=1.6)
dot_field(pax, 0, 0, 30, PINK, 'o', 14, 8)
return None
if pt['living']:
how = '지금은 함께 사는 중 · 따로 산 뒤 %.0f주마다 %s시간' % (pt['cyc'], '{:,.0f}'.format(pt['hrs']))
else:
how = '%.0f주마다 %s시간씩 · %.0f세 기준' % (pt['cyc'], '{:,.0f}'.format(pt['hrs']), pt['target'])
ax.text(X, 0.573, how, fontsize=fs(7.3), color=SUB, va='center')
ax.text(X, 0.527, '앞으로 약 %s번' % '{:,}'.format(int(pt['v_any'])), fontsize=fs(18), color=PINK, fontweight='bold', va='center')
ax.text(X, 0.482, '함께할 시간 약 %s일 (여행 포함)' % '{:,}'.format(int(pt['fut_h'] / 24.0)), fontsize=fs(9.2),
color=INK, fontweight='bold', va='center')
ax.text(X, 0.455, ' · '.join('%s %s번' % (nm, '{:,}'.format(int(v))) for nm, v in pt['visits'].items()),
fontsize=fs(7.3), color=SUB, va='center')
cols, max_dots = 30, 240
v_any = pt['v_any']
unit = max(1, int(math.ceil(v_any / max_dots))) if v_any > 0 else 1
n_any = int(math.ceil(v_any / unit)) if v_any > 0 else 0
dot_field(pax, n_any, int(math.floor(pt['v_both'] / unit)), cols, PINK, 'o', 14, 8)
note = '● 한 점 = 만남 %d번' % unit + (' · 흐린 점 = 한 분만 계신 시기' if pt['v_both'] < v_any - 0.5 else '')
ax.text(X, 0.148, note, fontsize=fs(6.8), color=SUB, va='center')
used = pt['used']
W = 0.30
ax.text(X, 0.112, '평생 함께할 시간 중 이미 %.0f%%를 썼어요' % (used * 100), fontsize=fs(8.2), color=PINK, fontweight='bold', va='center')
ax.add_patch(Rectangle((X, 0.072), W, 0.016, fc=CARD, ec='none', transform=ax.transAxes))
ax.add_patch(Rectangle((X, 0.072), W * used, 0.016, fc=PINK, ec='none', alpha=0.85, transform=ax.transAxes))
ax.text(X + W, 0.045, '남은 %.0f%%' % ((1 - used) * 100), fontsize=fs(7.3), color=INK, va='center', ha='right')
return None
def draw_trips(ax, tax, pt: dict, prof: dict, now: _dt.date, X: float) -> None:
ax.text(X, 0.600, '부모님과 남은 여행', fontsize=fs(10), color=MINT, fontweight='bold', va='center')
plans = prof['trips']
fut = [t for t in plans if t[0] >= now]
if pt and pt['rem_max'] > 0:
ax.text(X, 0.573, '%.0f개월마다 %s일 여행 · %.0f세 기준' % (pt['t_cyc'], '{:,.0f}'.format(pt['t_days']), pt['target']),
fontsize=fs(7.3), color=SUB, va='center')
ax.text(X, 0.527, '앞으로 약 %s번' % '{:,}'.format(int(pt['trips'])), fontsize=fs(18), color=MINT, fontweight='bold', va='center')
ax.text(X, 0.482, '함께할 여행 약 %s일' % '{:,}'.format(int(pt['trip_days'])), fontsize=fs(9.2), color=INK,
fontweight='bold', va='center')
n = int(math.ceil(pt['trips']))
unit = max(1, int(math.ceil(n / 60.0)))
dot_field(tax, int(math.ceil(n / unit)), int(math.floor(pt['trips_both'] / unit)), 20, MINT, '^', 26, 3)
ax.text(X, 0.455, '▲ 한 개 = 여행 %d번' % unit, fontsize=fs(7.3), color=SUB, va='center')
else:
ax.text(X, 0.52, '부모님 나이를 입력하면\n남은 여행 횟수를 보여 줍니다.', fontsize=fs(8.5), color=SUB, va='center', linespacing=1.6)
dot_field(tax, 0, 0, 20, MINT, '^', 26, 3)
ax.text(X, 0.300, '여행 계획', fontsize=fs(9.2), color=INK, fontweight='bold', va='center')
if fut:
nd, nm, ndays = fut[0]
ax.text(X + 0.30, 0.300, '다음 여행 %s' % dday((nd - now).days), fontsize=fs(9.2), color=MINT, fontweight='bold',
va='center', ha='right')
if not plans:
ax.text(X, 0.225, '처음 입력 창의 "부모님과 여행 계획"에\n날짜 · 장소 · 일수를 넣으면\n여기에 D-day가 표시됩니다.',
fontsize=fs(7.6), color=SUB, va='center', linespacing=1.6)
return None
past = [t for t in plans if t[0] < now]
shown = (past[-1:] + fut)[:7]
for i, (d, name, days) in enumerate(shown):
y = 0.268 - i * 0.031
done = d < now
ax.text(X, y, '▲', fontsize=fs(7.5), color=SUB if done else MINT, va='center')
ax.text(X + 0.022, y, '%s · %d일' % (name, days), fontsize=fs(7.8), color=SUB if done else INK, va='center')
ax.text(X + 0.215, y, d.strftime('%y.%m.%d'), fontsize=fs(7.2), color=SUB, va='center', ha='right')
ax.text(X + 0.30, y, '다녀옴' if done else dday((d - now).days), fontsize=fs(7.4), color=SUB if done else MINT,
va='center', ha='right')
return None
def draw_panel(ax, p: dict, prof: dict, now: _dt.date, pt: dict, art: dict) -> dict:
birth = prof['birth']
ax.set_position([0.345, 0.0, 0.64, 1.0])
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_axis_off()
days = max(0, (now - birth).days)
frac = min(1.0, days / float((add_years(birth, 100) - birth).days))
age, wk = week_pos(birth, now)
season = season_of(age)
dy = (now - prof['today']).days / 365.25
tag = (' · %d년 뒤의 나' % round(dy)) if dy > 0.5 else ((' · %d년 전의 나' % round(-dy)) if dy < -0.5 else '')
ax.text(0.16, 0.968, '인생을 하루로 치면, 지금은' + tag, fontsize=fs(8.8), color=SUB, va='center')
ax.text(0.16, 0.928, life_clock(frac), fontsize=fs(22), color=GOLD, fontweight='bold', va='center')
ax.text(0.16, 0.886, '%s · %d세 %d주차 · 100년 중 %.1f%% · 인생의 %s (%s)' % (
now.strftime('%Y.%m.%d'), age, wk + 1, frac * 100, season[2], season[3]), fontsize=fs(8.1), color=INK, va='center')
ax.text(1.0, 0.968, '지금 이 순간에도', fontsize=fs(7.3), color=SUB, va='center', ha='right')
art['live_s'] = ax.text(1.0, 0.938, '', fontsize=fs(12), color=INK, fontweight='bold', va='center', ha='right')
art['live_h'] = ax.text(1.0, 0.907, '', fontsize=fs(7.8), color=PINK, va='center', ha='right')
ax.text(0.0, 0.848, '지금까지', fontsize=fs(10), color=INK, fontweight='bold', va='center')
stats = [
('{:,}일'.format(days), '살아온 날'),
(kr_num(days * 1440 * 72.0) + '번', '심장이 뛴 횟수'),
(kr_num(days * 1440 * 16.0) + '번', '숨 쉰 횟수'),
('%d번' % int(days / 29.530589), '본 보름달'),
(kr_num(days / 365.25 * 9.4e8) + 'km', '태양 주위를 달린 거리'),
(kr_num(days * 3.0) + '끼', '먹은 밥'),
]
cw, gx = (1 - 5 * 0.012) / 6, 0.012
for i, (big, small) in enumerate(stats):
card(ax, i * (cw + gx), 0.762, cw, 0.064, big, small, INK, 10.5)
yrs = time_budget(p, now, birth, pt)
ax.text(0.0, 0.727, '100세까지 남은 %.1f년, 어디에 쓰일까' % sum(yrs.values()), fontsize=fs(10), color=INK, fontweight='bold', va='center')
ax.text(1.0, 0.727, '온전히 내 것인 시간 %.1f년' % yrs['내 시간'], fontsize=fs(10), color=GOLD, fontweight='bold', va='center', ha='right')
XC = 0.665
d100 = add_years(birth, 100)
ret = add_years(birth, int(p['은퇴 나이']))
rem = max(0, (d100 - now).days)
counters = [
('{:,}'.format(rem // 7), '남은 주말', INK),
('%d' % count_april(now, d100), '남은 벚꽃 계절', INK),
(('%d' % pt['holidays']) if pt else '-', '부모님과 남은 명절', PINK),
('{:,}'.format(max(0, (ret - now).days) // 7), '은퇴까지 남은 월요일', GOLD),
]
ax.text(XC, 0.600, '남은 것들', fontsize=fs(10), color=INK, fontweight='bold', va='center')
for i, (big, small, col) in enumerate(counters):
card(ax, XC + (i % 2) * 0.172, 0.500 - (i // 2) * 0.078, 0.163, 0.070, big, small, col, 12)
ax.text(XC, 0.378, '이정표', fontsize=fs(10), color=INK, fontweight='bold', va='center')
ms = prof['ms']
past = [m for m in ms if m[0] <= now]
fut = [m for m in ms if m[0] > now]
shown = (past[-2:] + fut)[:10]
for i, (d, label, kind) in enumerate(shown):
y = 0.345 - i * 0.031
done = d <= now
scol = SUB if done else (GOLD if kind == 'm' else PINK)
ax.text(XC, y, '★' if kind == 'm' else '◆', fontsize=fs(8), color=scol, va='center')
ax.text(XC + 0.022, y, label, fontsize=fs(7.8), color=SUB if done else INK, va='center')
ax.text(XC + 0.225, y, d.strftime('%y.%m.%d'), fontsize=fs(7.2), color=SUB, va='center', ha='right')
ax.text(1.0, y, '지남' if done else dday((d - now).days), fontsize=fs(7.4), color=SUB if done else GOLD, va='center', ha='right')
return {'frac': frac, 'age': age, 'week': wk + 1, 'days': days, 'yrs': yrs, 'season': season[2]}
def update_live(art: dict, prof: dict, now: _dt.date) -> None:
shift = _dt.timedelta(days=(now - prof['today']).days)
t = _dt.datetime.now() + shift
sec = max(0.0, (t - _dt.datetime.combine(prof['birth'], _dt.time())).total_seconds())
if 'live_s' in art:
art['live_s'].set_text('살아온 %s초' % '{:,}'.format(int(sec)))
art['live_h'].set_text('♥ 심장은 %s번째 뛰는 중' % '{:,}'.format(int(sec * 1.2)))
return None
def hover_info(prof: dict, rc: tuple, now: _dt.date) -> tuple:
birth = prof['birth']
d = cell_date(birth, rc)
s = season_of(rc[0])
n = (d - now).days
when = '이번 주' if abs(n) < 7 else ('%s년 뒤' % '{:.1f}'.format(n / 365.25) if n > 365 else
('D-%d' % n if n > 0 else ('%s년 전' % '{:.1f}'.format(-n / 365.25) if n < -365 else '%d일 전' % -n)))
line1 = '%s 주 · %d세 %d주차 · 인생의 %s · %s' % (d.strftime('%Y.%m.%d'), rc[0], rc[1] + 1, s[2], when)
tags = ['★ ' + label for md, label, _ in prof['ms'] if week_pos(birth, md) == rc]
tags += ['▲ %s %d일' % (nm, dd) for td, nm, dd in prof['trips'] if week_pos(birth, td) == rc]
ps = ['%s %d세' % (nm, int(a)) for nm, a in parents_at(prof, d) if 0 <= a]
return line1, ' · '.join(tags + ps)
# ── 움직이는 계절 배경 (배경색 픽셀에만 그려서 글자·카드 뒤로 지나가게 함) ─────────────
SEASON_FX = {
'봄': {'n': 42, 'colors': ['#FFB7C5', '#FFC8D6', '#FFD9E3'], 'size': (4.5, 7.5)},
'여름': {'n': 46, 'colors': ['#D9FF73', '#F4FF9A', '#B8FF6A'], 'size': (2.2, 3.6)},
'가을': {'n': 32, 'colors': ['#FF8A3D', '#E0592A', '#FFB74D', '#C9763A'], 'size': (5.5, 8.5)},
'겨울': {'n': 90, 'colors': ['#FFFFFF', '#E6EEFF'], 'size': (1.3, 3.2)},
}
def fx_init(season: str, rng) -> dict:
cfg = SEASON_FX[season]
n = cfg['n']
return {
'season': season, 'n': n,
'x': rng.random(n), 'y': rng.random(n),
'vx': rng.normal(0, 0.0006, n), 'vy': rng.uniform(0.6, 1.0, n),
'size': rng.uniform(cfg['size'][0], cfg['size'][1], n),
'ph': rng.uniform(0, 2 * np.pi, n), 'rot': rng.uniform(0, np.pi, n),
'col': np.array([hex_rgb(cfg['colors'][i % len(cfg['colors'])]) for i in rng.integers(0, 99, n)]),
't': 0.0, 'rng': rng,
}
def fx_step(fx: dict) -> None:
s, t, rng, n = fx['season'], fx['t'], fx['rng'], fx['n']
fx['t'] = t + 1.0
if s == '여름':
fx['vx'] = np.clip(fx['vx'] + rng.normal(0, 0.00025, n), -0.0016, 0.0016)
vy = np.clip(np.sin(t * 0.03 + fx['ph']) * 0.0009 - 0.00025, -0.0016, 0.0016)
fx['x'] = (fx['x'] + fx['vx']) % 1.0
fx['y'] = (fx['y'] + vy) % 1.0
return None
speed = {'봄': 0.0020, '가을': 0.0026, '겨울': 0.0016}[s]
sway = {'봄': 0.0016, '가을': 0.0024, '겨울': 0.0007}[s]
fx['y'] = fx['y'] + fx['vy'] * speed
fx['x'] = fx['x'] + np.sin(t * 0.045 + fx['ph']) * sway + 0.0004
fx['rot'] = fx['rot'] + 0.04 * np.sin(fx['ph'] + t * 0.02) + (0.05 if s == '가을' else 0.02)
out = fx['y'] > 1.03
if out.any():
fx['y'][out] = -0.03
fx['x'][out] = rng.random(int(out.sum()))
fx['x'] = fx['x'] % 1.0
return None
def fx_stamp(buf: np.ndarray, base: np.ndarray, mask: np.ndarray, fx: dict, scale: float) -> list:
H, W = mask.shape
s = fx['season']
wins = []
for i in range(fx['n']):
r = fx['size'][i] * scale
R = int(math.ceil(r * (3.0 if s == '여름' else 1.6))) + 1
cx, cy = fx['x'][i] * W, fx['y'][i] * H
x0, x1 = max(0, int(cx) - R), min(W, int(cx) + R + 1)
y0, y1 = max(0, int(cy) - R), min(H, int(cy) + R + 1)
if x1 <= x0 or y1 <= y0:
continue
yy, xx = np.mgrid[y0:y1, x0:x1]
dx, dy = xx - cx, yy - cy
if s == '여름':
blink = 0.5 + 0.5 * math.sin(fx['t'] * 0.09 + fx['ph'][i])
a = (0.95 * np.exp(-(dx * dx + dy * dy) / (2 * (0.55 * r) ** 2)) +
0.45 * np.exp(-(dx * dx + dy * dy) / (2 * (1.9 * r) ** 2))) * blink
elif s == '겨울':
a = 0.85 * np.exp(-(dx * dx + dy * dy) / (2 * (0.6 * r) ** 2))
else:
c, sn = math.cos(fx['rot'][i]), math.sin(fx['rot'][i])
u, v = dx * c + dy * sn, -dx * sn + dy * c
e = (u / r) ** 2 + (v / (r * (0.55 if s == '봄' else 0.42))) ** 2
a = np.clip(1.25 - e, 0, 1) * (0.80 if s == '봄' else 0.88)
a = (a * mask[y0:y1, x0:x1])[:, :, None]
cur = buf[y0:y1, x0:x1, :3].astype(np.float32)
buf[y0:y1, x0:x1, :3] = (cur * (1 - a) + fx['col'][i] * 255.0 * a).astype(np.uint8)
wins.append((y0, y1, x0, x1))
return wins
def ask_profile() -> dict:
got = mIO.form([
{"type": "section", "text": "── 나 ──"},
{"type": "text", "label": "이름", "default": "나"},
{"type": "text", "label": "생년월일 (YYYY-MM-DD)", "default": "1990-01-01"},
{"type": "text", "label": "나의 기념일 (선택)", "default": ""},
{"type": "section", "text": "예) 2015-03-02 입사; 2020-10-10 결혼"},
{"type": "section", "text": "── 부모님 (0 = 생략) ──"},
{"type": "spin", "label": "아버지 현재 나이", "min": 0, "max": 110, "default": 0, "step": 1},
{"type": "spin", "label": "어머니 현재 나이", "min": 0, "max": 110, "default": 0, "step": 1},
{"type": "spin", "label": "부모님과 따로 살기 시작한 나이 (아직 함께 살면 0)", "min": 0, "max": 99, "default": 20, "step": 1},
{"type": "text", "label": "부모님과 여행 계획 (선택)", "default": ""},
{"type": "section", "text": "예) 2026-12-20 제주도 3; 2027-05-01 오사카 4 (날짜 장소 일수)"},
], title="인생 100년 달력", width=600)
if got is None:
return {}
try:
birth = parse_date(got.get("생년월일 (YYYY-MM-DD)", ""))
except Exception:
mIO.print("[에러] 생년월일을 YYYY-MM-DD 형식으로 입력하세요.")
return {}
today = _dt.date.today()
if birth > today or birth < add_years(today, -99):
mIO.print("[에러] 생년월일은 오늘 기준 99년 이내 과거여야 합니다.")
return {}
parents = [(nm, float(got.get(k) or 0)) for nm, k in (('아버지', "아버지 현재 나이"), ('어머니', "어머니 현재 나이"))]
return {
'name': (got.get("이름") or "나").strip() or "나",
'birth': birth, 'today': today, 'parents': [(nm, a) for nm, a in parents if a > 0],
'indep': float(got.get("부모님과 따로 살기 시작한 나이 (아직 함께 살면 0)") or 0),
'ms': milestones(birth, parse_events(got.get("나의 기념일 (선택)", ""))),
'trips': parse_trips(got.get("부모님과 여행 계획 (선택)", "")),
'layers': grid_layers(),
}
def maximize_window(fig) -> None:
try:
from matplotlib.backends.qt_compat import QtCore
w = fig.canvas.window()
w.setMinimumWidth(600)
w.setMaximumSize(16777215, 16777215)
Q = QtCore.Qt.WindowType
w.setWindowFlags(w.windowFlags() | Q.WindowMaximizeButtonHint | Q.WindowMinimizeButtonHint | Q.WindowSystemMenuHint)
QtCore.QTimer.singleShot(0, w.showMaximized)
except Exception:
pass
return None
def run_life_calendar() -> pd.DataFrame:
plt.rcParams['font.family'] = 'Malgun Gothic'
plt.rcParams['axes.unicode_minus'] = False
prof = ask_profile()
if not prof:
return pd.DataFrame()
birth, today = prof['birth'], prof['today']
d_last = add_years(birth, 100) - _dt.timedelta(days=1)
max_travel = max(0, 99 - (today.year - birth.year))
rng = np.random.default_rng()
bg_rgb = (hex_rgb(BG) * 255).round().astype(np.uint8)
st = {'axes': None, 'ax': None, 'p': None, 'pin': None, 'play': None, 'alive': True,
'timer': None, 'hover': None, 'art': {}, 'out': None, 'beat': 0, 'frame': 0,
'fx': None, 'base': None, 'mask': None, 'wins': [], 'size': None}
def view_date() -> _dt.date:
if st['play'] is not None:
return min(add_years(birth, int(st['play'])), d_last)
if st['pin'] is not None:
return st['pin']
return min(add_years(today, int(round(st['p']['시간 여행 (년 뒤)']))), d_last)
def render() -> None:
gax, cax, bax, pax, tax = st['axes']
ax = st['ax']
fig = ax.figure
w, h = fig.get_size_inches()
UI['s'] = max(0.6, min(w / BASE_W, h / BASE_H))
ax.cla()
now = view_date()
pt = parent_time(st['p'], prof, now)
art = {}
draw_grid(gax, prof, now, pt, art)
out = draw_panel(ax, st['p'], prof, now, pt, art)
draw_clock(cax, out['frac'], st['play'] is not None)
draw_budget(bax, out['yrs'])
draw_parents(ax, pax, pt, 0.0)
draw_trips(ax, tax, pt, prof, now, 0.33)
update_live(art, prof, now)
if st['fx'] is None or st['fx']['season'] != out['season']:
st['fx'] = fx_init(out['season'], rng)
out['pt'] = pt
st['art'], st['out'], st['hover'] = art, out, None
return None
def canvas_alive(fig) -> bool:
try:
vis = getattr(fig.canvas, 'isVisible', None)
return st['alive'] and (vis() if callable(vis) else True)
except Exception:
return False
def fx_frame(advance: bool) -> bool:
fig = st['ax'].figure
if st['base'] is None or st['fx'] is None:
return False
buf = np.asarray(fig.canvas.buffer_rgba())
if buf.shape[:2] != st['base'].shape[:2]:
return False
for (y0, y1, x0, x1) in st['wins']:
buf[y0:y1, x0:x1, :3] = st['base'][y0:y1, x0:x1]
if advance:
fx_step(st['fx'])
st['wins'] = fx_stamp(buf, st['base'], st['mask'], st['fx'], buf.shape[0] / 800.0)
return True
def on_draw(ev) -> None:
try:
fig = st['ax'].figure
if getattr(ev, 'renderer', None) is not fig.canvas.get_renderer():
return
buf = np.asarray(fig.canvas.buffer_rgba())
st['base'] = buf[:, :, :3].copy()
st['mask'] = np.all(st['base'] == bg_rgb, axis=2).astype(np.float32)
st['wins'] = []
fx_frame(False)
except Exception:
st['base'] = None
return None
def on_tick() -> None:
fig = st['ax'].figure
try:
if not canvas_alive(fig):
st['timer'].stop()
return
st['frame'] += 1
if st['play'] is not None:
if st['frame'] % 3 == 0:
st['play'] += 1.0
if st['play'] > 100:
st['play'] = None
render()
fig.canvas.draw_idle()
return
elif st['frame'] % int(1000 / FPS_MS) == 0:
update_live(st['art'], prof, view_date())
st['beat'] ^= 1
if 'glow' in st['art']:
st['art']['glow'].set_sizes([(150 if st['beat'] else 80) * UI['s'] ** 2])
st['art']['glow'].set_alpha(0.45 if st['beat'] else 1.0)
fig.canvas.draw_idle()
return
if fx_frame(True):
try:
fig.canvas.blit(fig.bbox)
except Exception:
fig.canvas.draw_idle()
except Exception:
try:
st['timer'].stop()
except Exception:
pass
return None
def on_click(ev) -> None:
try:
gax, cax = st['axes'][0], st['axes'][1]
if ev.inaxes is cax:
st['play'] = 0.0 if st['play'] is None else None
st['pin'] = None
render()
elif ev.inaxes is gax and st['play'] is None:
rc = xy_cell(ev.xdata, ev.ydata)
if rc is None:
return
d = min(cell_date(birth, rc), d_last)
st['pin'] = None if week_pos(birth, d) == week_pos(birth, today) else d
render()
else:
return
ev.canvas.draw_idle()
except Exception:
pass
return None
def on_move(ev) -> None:
try:
gax = st['axes'][0]
art = st['art']
rc = xy_cell(ev.xdata, ev.ydata) if ev.inaxes is gax else None
if rc == st['hover'] or 'hover_txt' not in art:
return
st['hover'] = rc
if rc is None:
art['hover_mark'].set_offsets([[np.nan, np.nan]])
art['hover_txt'].set_text('달력에 마우스를 올려 보세요 · 클릭하면 그 주로 이동')
art['hover_txt'].set_color(SUB)
art['hover_txt2'].set_text('')
else:
l1, l2 = hover_info(prof, rc, view_date())
art['hover_mark'].set_offsets([list(cell_xy(*rc))])
art['hover_txt'].set_text(l1)
art['hover_txt'].set_color(INK)
art['hover_txt2'].set_text(l2)
ev.canvas.draw_idle()
except Exception:
pass
return None
def on_resize(ev) -> None:
try:
if st['axes'] is not None and st['p'] is not None:
render()
except Exception:
pass
return None
def plot_func(p: dict, ax) -> None:
fig = ax.figure
st['ax'], st['p'], st['pin'], st['play'] = ax, p, None, None
if st['axes'] is None:
try:
fig.set_layout_engine('none')
except Exception:
fig.set_tight_layout(False)
fig.patch.set_facecolor(BG)
st['axes'] = (fig.add_axes([0.01, 0.02, 0.33, 0.96]),
fig.add_axes([0.345, 0.862, 0.085, 0.128]),
fig.add_axes([0.345, 0.648, 0.64, 0.052]),
fig.add_axes([0.345, 0.168, 0.30 * 0.64, 0.265]),
fig.add_axes([0.345 + 0.33 * 0.64, 0.330, 0.30 * 0.64, 0.105]))
fig.canvas.mpl_connect('button_press_event', on_click)
fig.canvas.mpl_connect('motion_notify_event', on_move)
fig.canvas.mpl_connect('draw_event', on_draw)
fig.canvas.mpl_connect('resize_event', on_resize)
maximize_window(fig)
try:
tm = fig.canvas.new_timer(interval=FPS_MS)
tm.add_callback(on_tick)
tm.start()
st['timer'] = tm
except Exception:
st['timer'] = None
render()
return None
mIO.interactive_plot(
params=[
{"label": "하루 수면 (시간)", "min": 4, "max": 10, "default": 7, "step": 0.5, "decimals": 1},
{"label": "하루 일 (시간, 평일)", "min": 0, "max": 14, "default": 9, "step": 0.5, "decimals": 1},
{"label": "은퇴 나이", "min": 40, "max": 85, "default": 65, "step": 1, "decimals": 0},
{"label": "하루 스마트폰 (시간)", "min": 0, "max": 10, "default": 3, "step": 0.5, "decimals": 1},
{"label": "시간 여행 (년 뒤)", "min": 0, "max": max_travel, "default": 0, "step": 1, "decimals": 0},
{"label": "부모님 만남 주기 (주)", "min": 1, "max": 52, "default": 4, "step": 1, "decimals": 0},
{"label": "만날 때 함께하는 시간", "min": 1, "max": 99999, "default": 5, "step": 1, "decimals": 0},
{"label": "부모님 기준 나이", "min": 70, "max": 110, "default": 90, "step": 1, "decimals": 0},
{"label": "부모님과 여행 주기 (개월)", "min": 1, "max": 120, "default": 12, "step": 1, "decimals": 0},
{"label": "여행 일정 (일)", "min": 1, "max": 9999, "default": 3, "step": 1, "decimals": 0},
],
plot_func=plot_func,
title="인생 100년 달력 — " + prof['name'],
layout="horizontal",
fig_width=11,
fig_height=8,
width=1300,
)
st['alive'] = False
o = st['out'] or {}
y = o.get('yrs', {})
pt = o.get('pt') or {}
nxt = [t for t in prof['trips'] if t[0] >= today]
return pd.DataFrame([{
"이름": prof['name'], "생년월일": birth.isoformat(),
"나이": o.get('age'), "주차": o.get('week'), "살아온_날": o.get('days'),
"인생_진행률_%": round(o.get('frac', 0) * 100, 2),
"인생시계": life_clock(o.get('frac', 0)),
"남은_내시간_년": round(y.get('내 시간', 0), 1),
"부모님_남은_만남_회": int(pt.get('v_any', 0)),
"부모님과_남은_여행_회": int(pt.get('trips', 0)),
"부모님과_남은_여행_일": int(pt.get('trip_days', 0)),
"부모님과_남은_시간_일": round(pt.get('fut_h', 0) / 24.0, 1),
"부모님과_시간_사용률_%": round(pt.get('used', 0) * 100, 1),
"다음_여행": ('%s %s (D-%d)' % (nxt[0][0].isoformat(), nxt[0][1], (nxt[0][0] - today).days)) if nxt else '',
}])
result = run_life_calendar()
댓글