sentiment analysis visualization
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)
)
))
);
}Sentiment analysis visualisation - matplotlib version
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()
Generate monthly diary sentiment
Monthly diary sentiment · 2026-09
-0.15Mean
6/6 (100%)Coverage
6Scored days
runtime error: UI contract at $.props.appearance.gap must be one of [0,1,2,3,4,6,8] stack traceback: [C]: in field 'row' lifelab-max/src/runtime/lua.rs:652:289: in main chunk
Show code
-- Monthly diary sentiment report controller.
-- Pin this block to journal/@end-of-month and click Run.
local OUTPUT_TAG = "" -- Exact published-page tag; empty keeps reports private.
local REPORT_PAGE = "reports/sentiment"
local TARGET_MONTH = "" -- Optional YYYY-MM override; empty uses the journal page/latest month.
local SUMMARY_MODE = "ai" -- "ai" summarizes aggregate scores only; "deterministic" stays local.
local FORCE_RESUMMARIZE = false
local SOURCE_TAG = "diary"
local SCORE_FIELD = "sentiment"
local REPORT_TAG = "sentiment/monthly"
local function push_unique(values, value)
if value == nil or value == "" then
return
end
for _, existing in ipairs(values) do
if existing == value then
return
end
end
values[#values + 1] = value
end
local function rounded(value, places)
local power = 10 ^ places
if value >= 0 then
return math.floor(value * power + 0.5) / power
end
return math.ceil(value * power - 0.5) / power
end
local function average_rows(rows, first_index, last_index)
local sum = 0
local count = 0
for index = first_index, last_index do
local row = rows[index]
if row ~= nil then
sum = sum + row.sentiment
count = count + 1
end
end
if count == 0 then
return 0
end
return sum / count
end
local function deterministic_summary(month, monthly_mean, coverage, rows)
local tone = "balanced"
if monthly_mean >= 0.20 then
tone = "positive-leaning"
elseif monthly_mean <= -0.20 then
tone = "negative-leaning"
end
local movement = "fairly steady"
if #rows >= 4 then
local midpoint = math.floor(#rows / 2)
local first_half = average_rows(rows, 1, midpoint)
local second_half = average_rows(rows, midpoint + 1, #rows)
local change = second_half - first_half
if change >= 0.15 then
movement = "more positive in the second half"
elseif change <= -0.15 then
movement = "more negative in the second half"
end
end
return string.format(
"%s was %s overall (mean %+.2f), with sentiment %s. Coverage was %d%% across %d scored day%s.",
month,
tone,
monthly_mean,
movement,
coverage,
#rows,
#rows == 1 and "" or "s"
)
end
local function build_signature(month, total, scored, rows)
local parts = {month, tostring(total), tostring(scored)}
for _, row in ipairs(rows) do
parts[#parts + 1] = string.format("%s:%+.6f:%d", row.date, row.sentiment, row.entries)
end
return table.concat(parts, "|")
end
local function build_aggregate_prompt(month, monthly_mean, coverage, total, scored, rows)
local series = {}
for _, row in ipairs(rows) do
series[#series + 1] = string.format(
"%s mean=%+.3f entries=%d",
row.date,
row.sentiment,
row.entries
)
end
return table.concat({
"Summarize this one-month sentiment series in 2-3 concise sentences.",
"Scores range from -1 (negative) to +1 (positive).",
"Describe overall tone, direction, and variability. Mention incomplete coverage when relevant.",
"Do not diagnose mental health, speculate about causes, or claim to have read journal text.",
"Month: " .. month,
string.format("Monthly mean: %+.3f", monthly_mean),
string.format("Coverage: %d%% (%d of %d diary blocks scored)", coverage, scored, total),
"Daily aggregates:",
table.concat(series, "\n"),
}, "\n")
end
local function build_frozen_report_code(month, summary, monthly_mean, coverage, total, scored, rows)
local lines = {
"local month = " .. string.format("%q", month),
"local summary = " .. string.format("%q", summary),
string.format("local monthly_mean = %.6f", monthly_mean),
string.format("local coverage = %d", coverage),
string.format("local total = %d", total),
string.format("local scored = %d", scored),
"local data = {",
}
for _, row in ipairs(rows) do
lines[#lines + 1] = string.format(
" {day=%q, sentiment=%.6f, entries=%d},",
row.day,
row.sentiment,
row.entries
)
end
lines[#lines + 1] = "}"
lines[#lines + 1] = [[
ui.column({
ui.heading("Monthly diary sentiment · " .. month, 3),
ui.row({
ui.stat("Mean", string.format("%+.2f", monthly_mean)),
ui.stat("Coverage", tostring(scored) .. "/" .. tostring(total) .. " (" .. tostring(coverage) .. "%)"),
ui.stat("Scored days", tostring(#data)),
}, {appearance = {gap = 12}}),
ui.text(summary),
}, {appearance = {gap = 8}})
local chart = nb.chart(data, {
x = "day",
y = "sentiment",
kind = "line",
title = "Daily mean sentiment (-1 to +1)",
height = 260,
})
ui.render(chart.renderer, chart)
ui.text("Generated from scored :diary: metadata only; no journal prose is embedded in this report.", {
appearance = {tone = "neutral", density = "compact"},
})
]]
return table.concat(lines, "\n")
end
local diary_blocks = nb.query_all({
tags = {SOURCE_TAG},
block_type = "note",
limit = 10000,
})
local context_page = (context and context.page) or ""
local target_month = string.match(TARGET_MONTH, "^(%d%d%d%d%-%d%d)$")
or string.match(context_page, "^journal/(%d%d%d%d%-%d%d)")
local latest_month = nil
for _, block in ipairs(diary_blocks) do
local page_title = block.page_title or ""
local month = string.match(page_title, "^journal/(%d%d%d%d%-%d%d)")
if month and (latest_month == nil or month > latest_month) then
latest_month = month
end
end
target_month = target_month or latest_month
if target_month == nil then
ui.alert("No :diary: blocks were found.", {
variant = "info",
title = "Monthly diary sentiment",
})
else
local days = {}
local total = 0
local scored = 0
local score_sum = 0
for _, block in ipairs(diary_blocks) do
local page_title = block.page_title or ""
local date = string.match(page_title, "^journal/(%d%d%d%d%-%d%d%-%d%d)")
if date and string.sub(date, 1, 7) == target_month then
total = total + 1
local metadata = block.metadata or {}
local sentiment = tonumber(metadata[SCORE_FIELD])
if sentiment ~= nil then
local day = days[date] or {sum = 0, count = 0}
day.sum = day.sum + sentiment
day.count = day.count + 1
days[date] = day
scored = scored + 1
score_sum = score_sum + sentiment
end
end
end
if scored == 0 then
ui.alert(
"No scored :diary: blocks for " .. target_month ..
". This view will fill automatically once diary scoring is enabled.",
{
variant = "info",
title = "Monthly diary sentiment",
}
)
else
local dates = {}
for date, _ in pairs(days) do
dates[#dates + 1] = date
end
table.sort(dates)
local rows = {}
for _, date in ipairs(dates) do
local item = days[date]
rows[#rows + 1] = {
date = date,
day = string.sub(date, 9, 10),
sentiment = rounded(item.sum / item.count, 6),
entries = item.count,
}
end
local monthly_mean = score_sum / scored
local coverage = total > 0 and math.floor((scored / total) * 100 + 0.5) or 0
local month_tag = "sentiment/month/" .. target_month
local existing_reports = nb.query_all({
block_type = "code",
tags = {REPORT_TAG, month_tag},
limit = 20,
})
local existing = existing_reports[1]
local signature = build_signature(target_month, total, scored, rows)
local fallback_summary = deterministic_summary(target_month, monthly_mean, coverage, rows)
local summary = fallback_summary
local summary_mode_used = "deterministic"
local existing_metadata = existing and existing.metadata or {}
local can_reuse_summary = not FORCE_RESUMMARIZE
and existing_metadata.sentiment_report_signature == signature
and existing_metadata.sentiment_report_requested_mode == SUMMARY_MODE
and type(existing_metadata.sentiment_report_summary) == "string"
and existing_metadata.sentiment_report_summary ~= ""
if can_reuse_summary then
summary = existing_metadata.sentiment_report_summary
summary_mode_used = existing_metadata.sentiment_report_summary_mode or SUMMARY_MODE
elseif SUMMARY_MODE == "ai" then
local prompt = build_aggregate_prompt(
target_month,
monthly_mean,
coverage,
total,
scored,
rows
)
local ok, response = pcall(function()
return nb.ai(prompt, {
system = "You write careful, non-clinical summaries of numeric sentiment aggregates.",
max_tokens = 220,
schema = {summary = "str"},
})
end)
if ok and type(response) == "table" and type(response.summary) == "string"
and response.summary ~= "" then
summary = response.summary
summary_mode_used = "ai"
end
end
ui.column({
ui.heading("Monthly diary sentiment · " .. target_month, 3),
ui.row({
ui.stat("Mean", string.format("%+.2f", monthly_mean)),
ui.stat("Coverage", tostring(scored) .. "/" .. tostring(total) .. " (" .. tostring(coverage) .. "%)"),
ui.stat("Scored days", tostring(#rows)),
}, {appearance = {gap = 12}}),
ui.text(summary),
}, {appearance = {gap = 8}})
local chart = nb.chart(rows, {
x = "day",
y = "sentiment",
kind = "line",
title = "Daily mean sentiment (-1 to +1)",
height = 260,
})
ui.render(chart.renderer, chart)
local report_tags = {"sentiment", REPORT_TAG, month_tag}
push_unique(report_tags, OUTPUT_TAG)
local report_metadata = {
auto_run = true,
nb_ctx = true,
collapsed = true,
sentiment_report_month = target_month,
sentiment_report_version = 1,
sentiment_report_source_tag = SOURCE_TAG,
sentiment_report_score_field = SCORE_FIELD,
sentiment_report_total = total,
sentiment_report_scored = scored,
sentiment_report_coverage = coverage,
sentiment_report_signature = signature,
sentiment_report_requested_mode = SUMMARY_MODE,
sentiment_report_summary_mode = summary_mode_used,
sentiment_report_summary = summary,
sentiment_report_publish_tag = OUTPUT_TAG,
}
local report_code = build_frozen_report_code(
target_month,
summary,
monthly_mean,
coverage,
total,
scored,
rows
)
local report_title = "Monthly diary sentiment · " .. target_month
nb.ensure_page(REPORT_PAGE)
if existing ~= nil then
nb.update(existing.id, {
content = report_code,
title = report_title,
language = "lua",
tags = report_tags,
metadata = report_metadata,
})
ui.alert("Updated the saved report on " .. REPORT_PAGE .. ".", {
variant = "success",
title = report_title,
})
else
nb.spawn(report_code, {
page = REPORT_PAGE,
block_type = "code",
language = "lua",
title = report_title,
tags = report_tags,
metadata = report_metadata,
})
ui.alert("Created the saved report on " .. REPORT_PAGE .. ".", {
variant = "success",
title = report_title,
})
end
if OUTPUT_TAG == "" then
ui.text("OUTPUT_TAG is empty, so the saved report remains private.", {
appearance = {tone = "neutral", density = "compact"},
})
else
ui.text("Saved report tagged :" .. OUTPUT_TAG .. ": for the published page with that title.", {
appearance = {tone = "neutral", density = "compact"},
})
end
end
end