sentiment analysis visualization
blog/lifelab/what is lifelab data scientist edition
code/sentiment analysis visualization
renderer
Sentiment Calendar Heatmap
Show code
function SentimentCalendarHeatmap({ data, monthLabel = 'Monthly View', daysInMonth = 31 }) {
const [hoveredDay, setHoveredDay] = useState(null);
if (!data || Object.keys(data).length === 0) {
return React.createElement('div', {
style: { padding: 20, color: css.textMuted, textAlign: 'center' }
}, 'No sentiment data available');
}
// Build a map of day-of-month -> { score, date string }
const dayMap = {};
Object.entries(data).forEach(([dateStr, value]) => {
const parts = dateStr.split('-');
const day = parseInt(parts[2], 10);
if (!isNaN(day) && day >= 1 && day <= daysInMonth) {
dayMap[day] = {
score: typeof value === 'object' ? value.avg || value.score || 0 : value,
dateStr: dateStr
};
}
});
const getColor = (score) => {
if (score === null || score === undefined) return css.bgSecondary;
const s = Math.max(-1, Math.min(1, score));
if (s < -0.3) return css.error;
if (s < 0) return css.warning;
if (s < 0.3) return css.bgSecondary;
if (s < 0.6) return css.success;
return css.primary;
};
const getLabel = (score) => {
if (score === null || score === undefined) return 'No data';
const s = Math.max(-1, Math.min(1, score));
if (s < -0.3) return 'Negative';
if (s < 0) return 'Slightly negative';
if (s < 0.3) return 'Neutral';
if (s < 0.6) return 'Positive';
return 'Very positive';
};
const days = [];
for (let d = 1; d <= daysInMonth; d++) {
days.push(d);
}
const rows = [];
const totalCells = Math.ceil(daysInMonth / 7) * 7;
const numRows = Math.ceil(daysInMonth / 7);
for (let r = 0; r < numRows; r++) {
const cells = [];
for (let c = 0; c < 7; c++) {
const dayNum = r * 7 + c + 1;
if (dayNum > daysInMonth) {
cells.push(React.createElement('div', {
key: 'empty-' + r + '-' + c,
style: { width: 40, height: 40 }
}));
} else {
const entry = dayMap[dayNum];
const score = entry ? entry.score : null;
const color = getColor(score);
const isHovered = hoveredDay === dayNum;
cells.push(React.createElement('div', {
key: 'day-' + dayNum,
onMouseEnter: () => setHoveredDay(dayNum),
onMouseLeave: () => setHoveredDay(null),
style: {
width: 40,
height: 40,
borderRadius: 6,
backgroundColor: color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
border: isHovered ? '2px solid ' + css.text : '2px solid transparent',
transition: 'border-color 0.15s',
position: 'relative'
}
},
React.createElement('span', {
style: { fontSize: 12, color: css.text, fontWeight: entry ? 600 : 400 }
}, dayNum),
isHovered && entry ? React.createElement('div', {
style: {
position: 'absolute',
bottom: 46,
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: css.bgPanel,
border: '1px solid ' + css.border,
borderRadius: 6,
padding: '6px 10px',
whiteSpace: 'nowrap',
zIndex: 10,
fontSize: 12,
color: css.text,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
}
},
React.createElement('div', { style: { fontWeight: 600 } }, entry.dateStr),
React.createElement('div', null, getLabel(score) + ' (' + score.toFixed(2) + ')')
) : null
));
}
}
rows.push(React.createElement('div', {
key: 'row-' + r,
style: { display: 'flex', gap: 4 }
}, ...cells));
}
const legendItems = [
{ label: 'Negative', color: css.error },
{ label: 'Slightly negative', color: css.warning },
{ label: 'Neutral', color: css.bgSecondary },
{ label: 'Positive', color: css.success },
{ label: 'Very positive', color: css.primary }
];
return React.createElement('div', {
style: { padding: 16, fontFamily: 'inherit' }
},
React.createElement('div', {
style: {
textAlign: 'center',
marginBottom: 12,
fontSize: 16,
fontWeight: 600,
color: css.textHeading
}
}, monthLabel),
React.createElement('div', {
style: { display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'center' }
}, ...rows),
React.createElement('div', {
style: {
display: 'flex',
gap: 12,
justifyContent: 'center',
marginTop: 14,
flexWrap: 'wrap'
}
}, ...legendItems.map(item =>
React.createElement('div', {
key: item.label,
style: { display: 'flex', alignItems: 'center', gap: 4 }
},
React.createElement('div', {
style: {
width: 14,
height: 14,
borderRadius: 3,
backgroundColor: item.color
}
}),
React.createElement('span', {
style: { fontSize: 11, color: css.textMuted }
}, item.label)
)
))
);
}renderer
Sentiment data query
Monthly View
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Negative
Slightly negative
Neutral
Positive
Very positive
Hide code
-- Query all blocks that have sentiment metadata and aggregate by date
local current_page = nb.current_page
local blocks
-- Check if we're on a journal page and extract date range
if current_page and current_page:match("^journal/") then
local date_str = current_page:match("[^/]+$") -- extract "2025-10-31"
local year, month, day = date_str:match("(%d%d%d%d)%-(%d%d)%-(%d%d)")
if year and month then
year, month = tonumber(year), tonumber(month)
local first_day = string.format("%04d-%02d-01", year, month)
-- Compute last day of month
local last_day = string.format("%04d-%02d-%02d", year, month, 31)
blocks = nb.query_all({
tags = {"dayone"},
date_from = first_day,
date_to = last_day
})
end
else
-- Not on journal page, query February 2026
blocks = nb.query_all({
tags = {"dayone"},
date_from = "2026-03-01",
date_to = "2026-03-31"
})
end
local by_date = {}
local counts = {}
for _, b in ipairs(blocks) do
local d = b.metadata.date or b.date
local s = tonumber(b.metadata.sentiment)
if d and s then
local key = string.sub(d, 1, 10)
by_date[key] = (by_date[key] or 0) + s
counts[key] = (counts[key] or 0) + 1
end
end
local result = {}
for date, total in pairs(by_date) do
result[date] = total / counts[date]
end
-- Extract month label and days in month from current context
local month_label = "Monthly View"
local days_in_month = 31
if current_page and current_page:match("^journal/") then
local date_str = current_page:match("[^/]+$")
local year, month = date_str:match("(%d%d%d%d)%-(%d%d)")
if year and month then
local month_names = {"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"}
month_label = month_names[tonumber(month)] .. " " .. year
-- Calculate days in month
local days = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
local y = tonumber(year)
if tonumber(month) == 2 and (y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0)) then
days_in_month = 29
else
days_in_month = days[tonumber(month)]
end
end
end
ui.render("sentiment-calendar-heatmap-v2", {
data = result,
monthLabel = month_label,
daysInMonth = days_in_month
})Sentiment analysis visualisation - matplotlib version
↳ from journal/2026-01-01
fig done
findfont: Failed to find font weight medium, now using 400.
Show code
from datetime import datetime
import calendar
from collections import defaultdict
import json
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from matplotlib.colors import LinearSegmentedColormap
def get_theme_colors(nb):
"""Fetch current CSS theme colors from settings."""
# FIX 1: query_all() takes a dict, not keyword args
blocks = nb.query_all(block_type="note",
metadata = {'css_enabled': True}
)
if blocks:
theme = blocks[0]['metadata']['variables']
return theme
return None
def sentiment_color(score, has_data=True, theme=None):
"""Map sentiment score using theme colors."""
if theme is None:
theme = {
'--bg-hover': '#f2e9e1',
'--hue-error': '#b4637a',
'--hue-success': '#286983',
}
if not has_data:
return theme['--bg-hover']
score = max(-1, min(1, score))
colors = [theme['--hue-error'], theme['--bg-hover'], theme['--hue-success']]
positions = [0, 0.5, 1]
cmap = LinearSegmentedColormap.from_list('sentiment', list(zip(positions, colors)))
normalized = (score + 1) / 2
rgba = cmap(normalized)
return '#{:02x}{:02x}{:02x}'.format(int(rgba[0]*255), int(rgba[1]*255), int(rgba[2]*255))
def generate_month(blocks, year, month, nb, figsize=(8, 7)):
"""Generate sentiment calendar using theme colors."""
theme = get_theme_colors(nb)
if theme is None:
theme = {
'--bg-app': '#faf4ed',
'--bg-hover': '#f2e9e1',
'--text-body': '#575279',
'--text-muted': '#797593',
'--hue-error': '#b4637a',
'--hue-success': '#286983',
}
# Group scores by day
daily_scores = defaultdict(list)
for block in blocks:
meta = block.get("metadata") or {}
if "sentiment" not in meta:
continue
page_title = block.get("page_title", "")
if page_title.startswith("journal/"):
try:
day = int(page_title.split("-")[-1])
score = meta["sentiment"]
if isinstance(score, str):
score = float(score)
daily_scores[day].append(score)
except (ValueError, IndexError):
continue
daily_avg = {day: sum(s)/len(s) for day, s in daily_scores.items()}
# Build calendar grid
cal = calendar.Calendar(firstweekday=6)
month_name = calendar.month_name[month]
fig, ax = plt.subplots(figsize=figsize, facecolor=theme['--bg-app'])
ax.set_facecolor(theme['--bg-app'])
ax.set_xlim(0, 7)
ax.set_ylim(-0.8, 7)
ax.set_aspect('equal')
ax.axis('off')
# Title
ax.text(3.5, 6.5, f"{month_name} {year}", ha='center', va='center',
fontsize=22, color=theme['--text-body'], fontweight='bold')
# Day headers
days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
for i, day in enumerate(days):
ax.text(i + 0.5, 5.75, day, ha='center', va='center',
fontsize=10, color=theme['--text-muted'], fontweight='medium')
# Calendar cells
row = 5
col = 0
for date in cal.itermonthdates(year, month):
if date.month != month:
col += 1
if col == 7:
col = 0
row -= 1
continue
has_data = date.day in daily_avg
score = daily_avg.get(date.day)
color = sentiment_color(score if score else 0, has_data, theme)
# Cell
rect = patches.FancyBboxPatch(
(col + 0.05, row - 0.95), 0.9, 0.9,
boxstyle="round,pad=0.02,rounding_size=0.08",
facecolor=color, edgecolor='none'
)
ax.add_patch(rect)
# Text color based on background
txt_color = theme['--text-body'] if has_data else theme['--text-muted']
# Day number
ax.text(col + 0.5, row - 0.38, str(date.day),
ha='center', va='center',
fontsize=13, color=txt_color, fontweight='bold')
# Score
if has_data:
ax.text(col + 0.5, row - 0.68, f"{score:+.1f}",
ha='center', va='center',
fontsize=8, color=txt_color, alpha=0.7)
col += 1
if col == 7:
col = 0
row -= 1
# Legend
legend_items = [
('Negative', theme['--hue-error']),
('Neutral', theme['--bg-hover']),
('Positive', theme['--hue-success'])
]
for i, (label, c) in enumerate(legend_items):
x = 1.5 + i * 2
rect = patches.FancyBboxPatch(
(x, -0.45), 0.3, 0.3,
boxstyle="round,pad=0.02,rounding_size=0.05",
facecolor=c, edgecolor='none'
)
ax.add_patch(rect)
ax.text(x + 0.45, -0.3, label, va='center', fontsize=9, color=theme['--text-muted'])
plt.tight_layout()
return fig, ax
# --- Main execution ---
date = datetime.strptime("2025-12-01", "%Y-%m-%d")
first_day = date.replace(day=1)
last_day = date.replace(day=calendar.monthrange(date.year, date.month)[1])
blocks = nb.query_all({
'metadata': {'source': 'dayone'},
'date_from': first_day.strftime("%Y-%m-%d"),
'date_to': last_day.strftime("%Y-%m-%d"),
})
fig, ax = generate_month(blocks, date.year, date.month, nb)
print("fig done")
plt.show()