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
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()