
@femdev
25Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.
hive.blog/@femdevVOTING POWER100.00%
DOWNVOTE POWER100.00%
RESOURCE CREDITS100.00%
REPUTATION PROGRESS0.00%
Net Worth
215.000USD
HIVE
30.953HIVE
HBD
0.000HBD
Effective Power
538.052HP
├── Own HP
506.546HP
└── Incoming DelegationsDeleg
+31.506HP
Detailed Balance
| HIVE | ||
| balance | 30.953HIVE | HIVE |
| market_balance | 0.000HIVE | HIVE |
| savings_balance | 0.000HIVE | HIVE |
| reward_hive_balance | 0.000HIVE | HIVE |
| HIVE POWER | ||
| Own HP | 506.546HP | HP |
| Delegated Out | 0.000HP | HP |
| Delegation In | 31.506HP | HP |
| Effective Power | 538.052HP | HP |
| Reward HP (pending) | 0.000HP | HP |
| HBD | ||
| hbd_balance | 0.000HBD | HBD |
| hbd_conversions | 0.000HBD | HBD |
| hbd_market_balance | 0.000HBD | HBD |
| savings_hbd_balance | 0.000HBD | HBD |
| reward_hbd_balance | 0.000HBD | HBD |
{
"balance": "30.953 HIVE",
"savings_balance": "0.000 HIVE",
"reward_hive_balance": "0.000 HIVE",
"vesting_shares": "822348.199557 VESTS",
"delegated_vesting_shares": "0.000000 VESTS",
"received_vesting_shares": "51148.767155 VESTS",
"hbd_balance": "0.000 HBD",
"savings_hbd_balance": "0.000 HBD",
"reward_hbd_balance": "0.000 HBD"
}Account Info
| name | femdev |
| id | 704428 |
| rank | 0 |
| reputation | 0 |
| created | 2018-01-31T21:13:06 |
| recovery_account | scipio |
| proxy | None |
| invited_by | null |
| post_count | 146 |
| comment_count | 0 |
| lifetime_vote_count | 0 |
| witnesses_voted_for | 0 |
| last_post | 2026-06-06T12:53:24 |
| last_root_post | 2026-06-06T12:53:24 |
| last_vote_time | 2026-06-06T12:53:33 |
| proxied_vsf_votes | 0, 0, 0, 0 |
| can_vote | 1 |
| voting_power | 9,800 |
| delayed_votes | None |
| governance_vote_expiration_ts | 1969-12-31T23:59:59 |
| balance | 30.953 HIVE |
| savings_balance | 0.000 HIVE |
| hbd_balance | 0.000 HBD |
| savings_hbd_balance | 0.000 HBD |
| vesting_shares | 822348.199557 VESTS |
| delegated_vesting_shares | 0.000000 VESTS |
| received_vesting_shares | 51148.767155 VESTS |
| reward_vesting_balance | 0.000000 VESTS |
| vesting_balance | 0.000 HIVE |
| vesting_withdraw_rate | 0.000000 VESTS |
| next_vesting_withdrawal | 1969-12-31T23:59:59 |
| withdrawn | 0 |
| to_withdraw | 0 |
| withdraw_routes | 0 |
| savings_withdraw_requests | 0 |
| last_account_recovery | 1970-01-01T00:00:00 |
| reset_account | null |
| last_owner_update | 2018-02-01T12:37:27 |
| last_account_update | 2026-02-15T00:22:57 |
| mined | No |
| hbd_seconds | 0 |
| hbd_last_interest_payment | 2019-09-23T20:53:18 |
| savings_hbd_last_interest_payment | 1970-01-01T00:00:00 |
{
"id": 704428,
"name": "femdev",
"owner": {
"weight_threshold": 1,
"account_auths": [],
"key_auths": [
[
"STM7KZKwyibAFkYc2jb6gLZLQjPDyVuzfjnS5rU5hrurcK5fyu2Bj",
1
]
]
},
"active": {
"weight_threshold": 1,
"account_auths": [],
"key_auths": [
[
"STM7RmcM9PgA3GPg8dcPzL7LzoiSvZnUsFX4rDjoXfYVcXx4cHBqX",
1
]
]
},
"posting": {
"weight_threshold": 1,
"account_auths": [],
"key_auths": [
[
"STM7vHX7tx89iw5VMg3JmQwV4p2SV9npWmhPpWfGtUpEuaSy2GJjT",
1
]
]
},
"memo_key": "STM8SuHVNJePs4kLBZarbJvzYsvenp8CVJ2gQEcJRhUoqbHuTDMps",
"json_metadata": "{\"profile\": {\"profile_image\": \"https://iili.io/qJA9iv4.png\", \"cover_image\": \"https://iili.io/qJA5IwB.jpg\", \"name\": \"femdev\", \"about\": \"Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.\", \"location\": \"Antwerpen, Belgium\"}}",
"posting_json_metadata": "{\"profile\": {\"profile_image\": \"https://iili.io/qJA9iv4.png\", \"cover_image\": \"https://iili.io/qJA5IwB.jpg\", \"name\": \"femdev\", \"about\": \"Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.\", \"location\": \"Antwerpen, Belgium\"}}",
"proxy": "",
"previous_owner_update": "1970-01-01T00:00:00",
"last_owner_update": "2018-02-01T12:37:27",
"last_account_update": "2026-02-15T00:22:57",
"created": "2018-01-31T21:13:06",
"mined": false,
"recovery_account": "scipio",
"last_account_recovery": "1970-01-01T00:00:00",
"reset_account": "null",
"comment_count": 0,
"lifetime_vote_count": 0,
"post_count": 146,
"can_vote": true,
"voting_manabar": {
"current_mana": 857550618114,
"last_update_time": 1780751157
},
"downvote_manabar": {
"current_mana": 218374241677,
"last_update_time": 1780751157
},
"voting_power": 9800,
"balance": "30.953 HIVE",
"savings_balance": "0.000 HIVE",
"hbd_balance": "0.000 HBD",
"hbd_seconds": "0",
"hbd_seconds_last_update": "2019-10-11T20:02:48",
"hbd_last_interest_payment": "2019-09-23T20:53:18",
"savings_hbd_balance": "0.000 HBD",
"savings_hbd_seconds": "0",
"savings_hbd_seconds_last_update": "1970-01-01T00:00:00",
"savings_hbd_last_interest_payment": "1970-01-01T00:00:00",
"savings_withdraw_requests": 0,
"reward_hbd_balance": "0.000 HBD",
"reward_hive_balance": "0.000 HIVE",
"reward_vesting_balance": "0.000000 VESTS",
"reward_vesting_hive": "0.000 HIVE",
"vesting_shares": "822348.199557 VESTS",
"delegated_vesting_shares": "0.000000 VESTS",
"received_vesting_shares": "51148.767155 VESTS",
"vesting_withdraw_rate": "0.000000 VESTS",
"post_voting_power": "873496.966712 VESTS",
"next_vesting_withdrawal": "1969-12-31T23:59:59",
"withdrawn": 0,
"to_withdraw": 0,
"withdraw_routes": 0,
"pending_transfers": 0,
"curation_rewards": 842,
"posting_rewards": 1852881,
"proxied_vsf_votes": [
0,
0,
0,
0
],
"witnesses_voted_for": 0,
"last_post": "2026-06-06T12:53:24",
"last_root_post": "2026-06-06T12:53:24",
"last_vote_time": "2026-06-06T12:53:33",
"post_bandwidth": 0,
"pending_claimed_accounts": 0,
"governance_vote_expiration_ts": "1969-12-31T23:59:59",
"delayed_votes": [],
"open_recurrent_transfers": 0,
"vesting_balance": "0.000 HIVE",
"reputation": 0,
"transfer_history": [],
"market_history": [],
"post_history": [],
"vote_history": [],
"other_history": [],
"witness_votes": [],
"tags_usage": [],
"guest_bloggers": [],
"rank": 0
}Withdraw Routes
| Incoming | Outgoing |
|---|---|
Empty | Empty |
{
"incoming": [],
"outgoing": []
}From Date
To Date
stem-shturmeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 14:25:00
stem-shturmeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 14:25:00
| voter | stem-shturm |
| author | femdev |
| weight | 133577198 |
| rshares | 133577198 |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| pending payout | 0.116 HBD |
| total vote weight | 1524762085786 |
| Transaction Info | Block #107037502/Trx 56efa2000c381b7a69d0c3a2894552f9d76d436d |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "stem-shturm",
"author": "femdev",
"weight": 133577198,
"rshares": 133577198,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"pending_payout": "0.116 HBD",
"total_vote_weight": 1524762085786
}
],
"block": 107037502,
"trx_id": "56efa2000c381b7a69d0c3a2894552f9d76d436d",
"op_in_trx": 1,
"timestamp": "2026-06-06T14:25:00",
"virtual_op": true,
"trx_in_block": 3
}2026/06/06 14:25:00
2026/06/06 14:25:00
| voter | stem-shturm |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| Transaction Info | Block #107037502/Trx 56efa2000c381b7a69d0c3a2894552f9d76d436d |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "stem-shturm",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
}
],
"block": 107037502,
"trx_id": "56efa2000c381b7a69d0c3a2894552f9d76d436d",
"op_in_trx": 0,
"timestamp": "2026-06-06T14:25:00",
"virtual_op": false,
"trx_in_block": 3
}jeronimorubioeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 13:09:15
jeronimorubioeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 13:09:15
| voter | jeronimorubio |
| author | femdev |
| weight | 11162786581 |
| rshares | 11162786581 |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| pending payout | 0.116 HBD |
| total vote weight | 1524628508588 |
| Transaction Info | Block #107035990/Trx 00f1c4037831df94ca81d40e373811586d5ab520 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "jeronimorubio",
"author": "femdev",
"weight": 11162786581,
"rshares": 11162786581,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"pending_payout": "0.116 HBD",
"total_vote_weight": 1524628508588
}
],
"block": 107035990,
"trx_id": "00f1c4037831df94ca81d40e373811586d5ab520",
"op_in_trx": 1,
"timestamp": "2026-06-06T13:09:15",
"virtual_op": true,
"trx_in_block": 12
}2026/06/06 13:09:15
2026/06/06 13:09:15
| voter | jeronimorubio |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| Transaction Info | Block #107035990/Trx 00f1c4037831df94ca81d40e373811586d5ab520 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "jeronimorubio",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
}
],
"block": 107035990,
"trx_id": "00f1c4037831df94ca81d40e373811586d5ab520",
"op_in_trx": 0,
"timestamp": "2026-06-06T13:09:15",
"virtual_op": false,
"trx_in_block": 12
}femdevclaimed reward balance: 0.637 HIVE, 0.648 HP2026/06/06 13:06:00
femdevclaimed reward balance: 0.637 HIVE, 0.648 HP
2026/06/06 13:06:00
| account | femdev |
| reward hbd | 0.000 HBD |
| reward hive | 0.637 HIVE |
| reward vests | 1052.362623 VESTS |
| Transaction Info | Block #107035925/Trx b86fa1c3c5a66b2bfe0854947e9290fe209f5bad |
View Raw JSON Data
{
"op": [
"claim_reward_balance",
{
"account": "femdev",
"reward_hbd": "0.000 HBD",
"reward_hive": "0.637 HIVE",
"reward_vests": "1052.362623 VESTS"
}
],
"block": 107035925,
"trx_id": "b86fa1c3c5a66b2bfe0854947e9290fe209f5bad",
"op_in_trx": 0,
"timestamp": "2026-06-06T13:06:00",
"virtual_op": false,
"trx_in_block": 1
}coinmarketcaleffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 12:58:30
coinmarketcaleffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 12:58:30
| voter | coinmarketcal |
| author | femdev |
| weight | 5296213683 |
| rshares | 5296213683 |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| pending payout | 0.115 HBD |
| total vote weight | 1513465722007 |
| Transaction Info | Block #107035775/Trx 3c7f11b6b894021a22149bf1dec011244b2ef9d3 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "coinmarketcal",
"author": "femdev",
"weight": 5296213683,
"rshares": 5296213683,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"pending_payout": "0.115 HBD",
"total_vote_weight": 1513465722007
}
],
"block": 107035775,
"trx_id": "3c7f11b6b894021a22149bf1dec011244b2ef9d3",
"op_in_trx": 1,
"timestamp": "2026-06-06T12:58:30",
"virtual_op": true,
"trx_in_block": 4
}2026/06/06 12:58:30
2026/06/06 12:58:30
| voter | coinmarketcal |
| author | femdev |
| weight | 2200 (22.00%) |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| Transaction Info | Block #107035775/Trx 3c7f11b6b894021a22149bf1dec011244b2ef9d3 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "coinmarketcal",
"author": "femdev",
"weight": 2200,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
}
],
"block": 107035775,
"trx_id": "3c7f11b6b894021a22149bf1dec011244b2ef9d3",
"op_in_trx": 0,
"timestamp": "2026-06-06T12:58:30",
"virtual_op": false,
"trx_in_block": 4
}newsrxeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 12:54:18
newsrxeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 12:54:18
| voter | newsrx |
| author | femdev |
| weight | 2061355180 |
| rshares | 2061355180 |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| pending payout | 0.114 HBD |
| total vote weight | 1508169508324 |
| Transaction Info | Block #107035691/Trx 889c69b2919b67abd49260decbf2680871eef74d |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "newsrx",
"author": "femdev",
"weight": 2061355180,
"rshares": 2061355180,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"pending_payout": "0.114 HBD",
"total_vote_weight": 1508169508324
}
],
"block": 107035691,
"trx_id": "889c69b2919b67abd49260decbf2680871eef74d",
"op_in_trx": 1,
"timestamp": "2026-06-06T12:54:18",
"virtual_op": true,
"trx_in_block": 22
}2026/06/06 12:54:18
2026/06/06 12:54:18
| voter | newsrx |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| Transaction Info | Block #107035691/Trx 889c69b2919b67abd49260decbf2680871eef74d |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "newsrx",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
}
],
"block": 107035691,
"trx_id": "889c69b2919b67abd49260decbf2680871eef74d",
"op_in_trx": 0,
"timestamp": "2026-06-06T12:54:18",
"virtual_op": false,
"trx_in_block": 22
}blue-witnesseffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 12:54:00
blue-witnesseffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 12:54:00
| voter | blue-witness |
| author | femdev |
| weight | 4288425448 |
| rshares | 4288425448 |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| pending payout | 0.114 HBD |
| total vote weight | 1506108153144 |
| Transaction Info | Block #107035685/Trx 7bff839f96d7857c8a7e6d2f78f1136541cde932 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "blue-witness",
"author": "femdev",
"weight": 4288425448,
"rshares": 4288425448,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"pending_payout": "0.114 HBD",
"total_vote_weight": 1506108153144
}
],
"block": 107035685,
"trx_id": "7bff839f96d7857c8a7e6d2f78f1136541cde932",
"op_in_trx": 1,
"timestamp": "2026-06-06T12:54:00",
"virtual_op": true,
"trx_in_block": 5
}2026/06/06 12:54:00
2026/06/06 12:54:00
| voter | blue-witness |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| Transaction Info | Block #107035685/Trx 7bff839f96d7857c8a7e6d2f78f1136541cde932 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "blue-witness",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
}
],
"block": 107035685,
"trx_id": "7bff839f96d7857c8a7e6d2f78f1136541cde932",
"op_in_trx": 0,
"timestamp": "2026-06-06T12:54:00",
"virtual_op": false,
"trx_in_block": 5
}steem-uaeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 12:53:48
steem-uaeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 12:53:48
| voter | steem-ua |
| author | femdev |
| weight | 513511629356 |
| rshares | 513511629356 |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| pending payout | 0.114 HBD |
| total vote weight | 1501819727696 |
| Transaction Info | Block #107035681/Trx b07afe3d4a39fb5e1454905d5b7dc4547bd84519 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "steem-ua",
"author": "femdev",
"weight": 513511629356,
"rshares": 513511629356,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"pending_payout": "0.114 HBD",
"total_vote_weight": 1501819727696
}
],
"block": 107035681,
"trx_id": "b07afe3d4a39fb5e1454905d5b7dc4547bd84519",
"op_in_trx": 1,
"timestamp": "2026-06-06T12:53:48",
"virtual_op": true,
"trx_in_block": 6
}2026/06/06 12:53:48
2026/06/06 12:53:48
| voter | steem-ua |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| Transaction Info | Block #107035681/Trx b07afe3d4a39fb5e1454905d5b7dc4547bd84519 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "steem-ua",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
}
],
"block": 107035681,
"trx_id": "b07afe3d4a39fb5e1454905d5b7dc4547bd84519",
"op_in_trx": 0,
"timestamp": "2026-06-06T12:53:48",
"virtual_op": false,
"trx_in_block": 6
}scipioeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 12:53:42
scipioeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 12:53:42
| voter | scipio |
| author | femdev |
| weight | 201130048271 |
| rshares | 201130048271 |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| pending payout | 0.075 HBD |
| total vote weight | 988308098340 |
| Transaction Info | Block #107035679/Trx 444491f62e06f9f43ff680a8fe01facca204df7a |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "scipio",
"author": "femdev",
"weight": 201130048271,
"rshares": 201130048271,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"pending_payout": "0.075 HBD",
"total_vote_weight": 988308098340
}
],
"block": 107035679,
"trx_id": "444491f62e06f9f43ff680a8fe01facca204df7a",
"op_in_trx": 1,
"timestamp": "2026-06-06T12:53:42",
"virtual_op": true,
"trx_in_block": 14
}2026/06/06 12:53:42
2026/06/06 12:53:42
| voter | scipio |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| Transaction Info | Block #107035679/Trx 444491f62e06f9f43ff680a8fe01facca204df7a |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "scipio",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
}
],
"block": 107035679,
"trx_id": "444491f62e06f9f43ff680a8fe01facca204df7a",
"op_in_trx": 0,
"timestamp": "2026-06-06T12:53:42",
"virtual_op": false,
"trx_in_block": 14
}femdeveffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 12:53:36
femdeveffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 12:53:36
| voter | femdev |
| author | femdev |
| weight | 17398892082 |
| rshares | 17398892082 |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| pending payout | 0.059 HBD |
| total vote weight | 787178050069 |
| Transaction Info | Block #107035677/Trx eb418dc9a451924d1c5d5a84a247b59e50500b89 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "femdev",
"author": "femdev",
"weight": 17398892082,
"rshares": 17398892082,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"pending_payout": "0.059 HBD",
"total_vote_weight": 787178050069
}
],
"block": 107035677,
"trx_id": "eb418dc9a451924d1c5d5a84a247b59e50500b89",
"op_in_trx": 1,
"timestamp": "2026-06-06T12:53:36",
"virtual_op": true,
"trx_in_block": 26
}2026/06/06 12:53:36
2026/06/06 12:53:36
| voter | femdev |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| Transaction Info | Block #107035677/Trx eb418dc9a451924d1c5d5a84a247b59e50500b89 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "femdev",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
}
],
"block": 107035677,
"trx_id": "eb418dc9a451924d1c5d5a84a247b59e50500b89",
"op_in_trx": 0,
"timestamp": "2026-06-06T12:53:36",
"virtual_op": false,
"trx_in_block": 26
}blueroboeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 12:53:33
blueroboeffective vote applied for @femdev / learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 12:53:33
| voter | bluerobo |
| author | femdev |
| weight | 769779157987 |
| rshares | 769779157987 |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| pending payout | 0.058 HBD |
| total vote weight | 769779157987 |
| Transaction Info | Block #107035676/Trx 7012070a5a67d2e3cde9d992bfc41c8d48146933 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "bluerobo",
"author": "femdev",
"weight": 769779157987,
"rshares": 769779157987,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"pending_payout": "0.058 HBD",
"total_vote_weight": 769779157987
}
],
"block": 107035676,
"trx_id": "7012070a5a67d2e3cde9d992bfc41c8d48146933",
"op_in_trx": 1,
"timestamp": "2026-06-06T12:53:33",
"virtual_op": true,
"trx_in_block": 7
}2026/06/06 12:53:33
2026/06/06 12:53:33
| voter | bluerobo |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| Transaction Info | Block #107035676/Trx 7012070a5a67d2e3cde9d992bfc41c8d48146933 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "bluerobo",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
}
],
"block": 107035676,
"trx_id": "7012070a5a67d2e3cde9d992bfc41c8d48146933",
"op_in_trx": 0,
"timestamp": "2026-06-06T12:53:33",
"virtual_op": false,
"trx_in_block": 7
}2026/06/06 12:53:30
2026/06/06 12:53:30
| id | reblog |
| json | ["reblog", {"account": "femdev", "author": "femdev", "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"}] |
| required auths | [] |
| required posting auths | ["femdev"] |
| Transaction Info | Block #107035675/Trx db101390b36b3e8eed5bb089d937ffe3f829dd0e |
View Raw JSON Data
{
"op": [
"custom_json",
{
"id": "reblog",
"json": "[\"reblog\", {\"account\": \"femdev\", \"author\": \"femdev\", \"permlink\": \"learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language\"}]",
"required_auths": [],
"required_posting_auths": [
"femdev"
]
}
],
"block": 107035675,
"trx_id": "db101390b36b3e8eed5bb089d937ffe3f829dd0e",
"op_in_trx": 0,
"timestamp": "2026-06-06T12:53:30",
"virtual_op": false,
"trx_in_block": 8
}femdevpublished a new post: learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language2026/06/06 12:53:27
femdevpublished a new post: learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
2026/06/06 12:53:27
| body | # Learn Creative Coding (#86) - Text as Data: Analyzing and Visualizing Language  Last episode we built network visualizations -- force-directed layouts, community detection, adjacency matrices, arc diagrams, interactive hover highlighting, edge bundling. We turned structural relationships into visual patterns. Nodes and edges, clusters and bridges, the social architecture of data made visible. But all the data we've worked with so far in this arc -- geographic coordinates, timestamps, network connections -- has been inherently numeric. Numbers map naturally to visual properties: position, size, color, angle. The mapping is direct. Text is different. Text is messy. A paragraph of English isn't a number. A word isn't a coordinate. Before you can visualize text, you have to *measure* it -- turn words and sentences into quantities that your canvas can work with. Word frequency, sentence length, character distribution, sentiment scores. Once you extract those measurements, text becomes data like any other. And the patterns hiding in language turn out to be surprisingly beautiful. This episode is about treating text as raw material for creative coding. We'll count words (and discover Zipf's law along the way), measure sentence rhythm, score sentiment with a dictionary lookup, map characters and words to colors, compare two texts side by side, and generate new text with Markov chains. We parsed structured data files back in episode 81 -- CSV rows and JSON objects with neat fields. Text is the unstructured cousin: no columns, no types, just a stream of characters that your code has to make sense of. ## Word frequency: the shape of language The most basic measurement of text: how often does each word appear? Count every word, sort by frequency, and you've got a profile of what the text is *about*. The most frequent words in any English text are always the same: "the", "a", "is", "and", "of", "to". These are called stop words -- they're structural, not meaningful. Filter them out and the content words that remain tell you the subject matter. A novel about whales will have "whale", "sea", "captain" near the top. A tech blog post will have "function", "data", "code". ```javascript function countWords(text) { const words = text.toLowerCase() .replace(/[^a-z\s]/g, '') .split(/\s+/) .filter(w => w.length > 0); const counts = {}; for (const word of words) { counts[word] = (counts[word] || 0) + 1; } return Object.entries(counts) .sort((a, b) => b[1] - a[1]); } const sampleText = `The quick brown fox jumps over the lazy dog. The dog barked at the fox. The fox ran away from the dog. A cat watched the dog chase the fox from the garden wall. The garden was quiet after the fox and the dog left.`; const freq = countWords(sampleText); // [["the", 10], ["dog", 4], ["fox", 4], ["the", ...], ...] ``` Ten occurrences of "the" in four sentences. Four each for "dog" and "fox". That's already telling you something -- this text is about a dog and a fox, which of course it is, but the algorithm doesn't know that. It just counted. The `replace(/[^a-z\s]/g, '')` strip is crude but effective -- it removes punctuation, digits, and special characters, leaving only lowercase letters and spaces. A proper tokenizer would handle apostrophes ("don't" should be one word, not "don" and "t"), hyphens ("well-known"), and unicode. For creative coding purposes, the crude version usually works fine. ## Zipf's law: the universal pattern Here's something wild. If you take any sufficiently long text in any natural language and plot word frequency on a log-log scale, you get a straight line. The most frequent word appears roughly twice as often as the second most frequent, three times as often as the third, and so on. Frequency times rank equals a constant. This is Zipf's law, and it holds for English, French, Japanese, Arabic, ancient Greek -- every natural language ever studied. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 500; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // generate synthetic word frequencies following Zipf's law // (in real use, feed in actual counted words from a text) const numWords = 200; const zipfFreqs = []; const maxFreq = 5000; for (let rank = 1; rank <= numWords; rank++) { // zipf: freq = C / rank^s, where s is close to 1 const freq = maxFreq / Math.pow(rank, 1.07); zipfFreqs.push({ rank, freq }); } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 500); // log-log plot const logMaxRank = Math.log10(numWords); const logMaxFreq = Math.log10(maxFreq); for (const { rank, freq } of zipfFreqs) { const logRank = Math.log10(rank); const logFreq = Math.log10(freq); const x = 60 + (logRank / logMaxRank) * 580; const y = 460 - (logFreq / logMaxFreq) * 420; ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.6)'; ctx.fill(); } // reference line (perfect Zipf) ctx.beginPath(); ctx.moveTo(60, 40); ctx.lineTo(640, 460); ctx.strokeStyle = 'rgba(255, 150, 100, 0.3)'; ctx.lineWidth = 1; ctx.stroke(); ``` The dots fall on a straight line. That straight line on a log-log plot means the relationship is a power law -- a mathematical signature that appears in city populations, earthquake magnitudes, website traffic, income distributions, and apparently every language humans have ever spoken. Nobody fully agrees on *why* Zipf's law holds for language. It's one of those patterns that seem too universal to be coincidence but too mysterious to explain from first principles. For creative coding, Zipf's law means word frequency distributions always have the same shape: a few very common words, a long tail of rare words. Your visualization has to handle that extreme range. Linear mapping won't work -- the top 5 words will dominate and everything else will be invisible. Log scaling (like we used for population density in episode 83) fixes that. ## Stop words and content filtering Those ultra-frequent words -- "the", "and", "is", "of", "to", "a", "in", "that" -- are the stop words. They're grammatically essential but semantically empty. For most text visualization, you want to remove them so the meaningful words shine through. ```javascript const stopWords = new Set([ 'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'can', 'of', 'to', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just', 'because', 'about', 'up', 'down', 'it', 'its', 'he', 'she', 'they', 'them', 'his', 'her', 'their', 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'us', 'our', 'you', 'your', 'who', 'which', 'what' ]); function contentWords(text) { return text.toLowerCase() .replace(/[^a-z\s]/g, '') .split(/\s+/) .filter(w => w.length > 1 && !stopWords.has(w)); } function contentWordFrequency(text) { const words = contentWords(text); const counts = {}; for (const word of words) { counts[word] = (counts[word] || 0) + 1; } return Object.entries(counts).sort((a, b) => b[1] - a[1]); } ``` Now instead of "the, a, is, and, of" you get the actual subject words. Feed in a speech by Martin Luther King and you'll get "dream", "freedom", "nation", "justice" at the top. Feed in a cooking recipe and you'll get "butter", "flour", "minutes", "stir". The content filter is the difference between seeing the skeleton of English grammar (same for every text) and seeing the fingerprint of *this particular* text. There's a creative argument for keeping stop words, though. Their frequency patterns reveal writing style, not content. One author might use "however" constantly while another never does. The ratio of "I" to "we" tells you something about perspective. If you're comparing *how* two texts are written rather than *what* they're about, the stop words are the signal, not the noise. ## Sentence length: the rhythm of prose Every writer has a rhythm. Hemingway wrote short, punchy sentences. Faulkner wrote sentences that stretched across half a page, nesting clause inside clause inside clause, circling back to pick up threads that seemed abandoned paragraphs ago. Visualizing sentence length turns that rhythmic fingerprint into a visible pattern. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 300; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); function getSentenceLengths(text) { // split on . ! ? (simplified -- doesn't handle abbreviations like "Dr." or "U.S.") const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); return sentences.map(s => s.trim().split(/\s+/).length); } // simulate two different writing styles const shortStyle = []; // hemingway-ish const longStyle = []; // faulkner-ish for (let i = 0; i < 40; i++) { shortStyle.push(4 + Math.floor(Math.random() * 8)); longStyle.push(15 + Math.floor(Math.random() * 30)); } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 300); const barWidth = 900 / 40 - 2; const maxLen = Math.max(...shortStyle, ...longStyle); // top row: short style for (let i = 0; i < 40; i++) { const h = (shortStyle[i] / maxLen) * 120; ctx.fillStyle = 'rgba(100, 200, 180, 0.6)'; ctx.fillRect(i * (barWidth + 2) + 1, 130 - h, barWidth, h); } // bottom row: long style for (let i = 0; i < 40; i++) { const h = (longStyle[i] / maxLen) * 120; ctx.fillStyle = 'rgba(200, 120, 180, 0.6)'; ctx.fillRect(i * (barWidth + 2) + 1, 170, barWidth, h); } ``` Two writers, same number of sentences. The top row is low and uniform -- short, choppy, rhythmically tight. The bottom row is tall and varied -- long, sprawling, rhythmically loose. You can see the writing style without reading a single word. The bar chart IS the prose rhythm, translated from temporal flow into spatial pattern. This is one of my favuorite text visualizations because it captures something that's genuinely hard to articulate in words. You *feel* writing rhythm when you read, but you can't easily describe it. "Hemingway writes short sentences" is true but doesn't tell you the *pattern* -- how his short sentences cluster, where the occasional long one breaks the rhythm for emphasis. The visualization shows all of that at once. ## Sentiment analysis: positive and negative Sentiment analysis rates text as positive, negative, or neutral. The simplest approach: use a word-level dictionary where each word has a pre-assigned score. The AFINN lexicon gives scores from -5 (very negative) to +5 (very positive) for about 2,477 English words. "Love" is +3. "Hate" is -3. "Disaster" is -3. "Excellent" is +3. Most words aren't in the dictionary at all -- they're neutral. ```javascript // simplified AFINN-style sentiment dictionary const sentiment = { 'love': 3, 'happy': 3, 'joy': 3, 'great': 3, 'excellent': 3, 'wonderful': 4, 'beautiful': 3, 'amazing': 4, 'good': 2, 'nice': 2, 'like': 1, 'best': 3, 'brilliant': 4, 'perfect': 3, 'hope': 2, 'win': 3, 'won': 3, 'success': 2, 'smile': 2, 'laugh': 1, 'hate': -3, 'terrible': -3, 'awful': -3, 'bad': -2, 'worst': -3, 'ugly': -2, 'stupid': -2, 'fail': -2, 'failed': -2, 'disaster': -3, 'horrible': -3, 'angry': -2, 'sad': -2, 'pain': -2, 'fear': -2, 'kill': -3, 'die': -2, 'death': -2, 'war': -2, 'destroy': -3, 'wrong': -2, 'broken': -1, 'hurt': -2, 'lost': -1, 'miss': -1 }; function scoreSentiment(text) { const words = text.toLowerCase().replace(/[^a-z\s]/g, '').split(/\s+/); let total = 0; let scored = 0; for (const word of words) { if (sentiment[word] !== undefined) { total += sentiment[word]; scored++; } } return { total, scored, average: scored > 0 ? total / scored : 0 }; } ``` This is crude. It doesn't understand negation ("not happy" should be negative but scores positive because "happy" is +3 and "not" isn't scored). It doesn't understand sarcasm, context, or intensity modifiers ("really happy" vs "happy"). But for creative coding, crude works. You're not building a production sentiment classifier -- you're extracting a signal that drives visual output. The imperfections add character. A text that's mostly positive with occasional negative spikes creates a visual rhythm that's more interesting than a perfectly calibrated flat score. ## Visualizing sentiment over a story The real magic happens when you score sentiment *per sentence* and plot it across a text. The emotional arc of a story becomes visible. Happy beginning, dark middle, triumphant ending? You'll see it as a color gradient shifting from warm to cold and back to warm. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 250; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // fake story: 30 sentences with an emotional arc // positive start, dip into negative, positive ending const storyScores = []; for (let i = 0; i < 30; i++) { const t = i / 29; // arc shape: starts positive, dips negative at 60%, recovers const arc = Math.sin(t * Math.PI * 2 - Math.PI * 0.3) * 2; const noise = (Math.random() - 0.5) * 1.5; storyScores.push(arc + noise); } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 250); const barW = 900 / 30 - 2; for (let i = 0; i < storyScores.length; i++) { const score = storyScores[i]; const x = i * (barW + 2) + 1; // positive = warm amber, negative = cool blue const normalized = (score + 4) / 8; // roughly -4 to +4 -> 0 to 1 const hue = normalized * 40 + (1 - normalized) * 220; const lightness = 25 + Math.abs(score) * 8; const barH = Math.abs(score) * 20 + 5; const y = score >= 0 ? 125 - barH : 125; ctx.fillStyle = `hsl(${hue}, 55%, ${lightness}%)`; ctx.fillRect(x, y, barW, barH); } // center line ctx.beginPath(); ctx.moveTo(0, 125); ctx.lineTo(900, 125); ctx.strokeStyle = 'rgba(80, 90, 110, 0.3)'; ctx.lineWidth = 1; ctx.stroke(); ``` Warm bars above the line: positive sentiment. Cool bars below: negative. The emotional arc is visible -- optimistic opening, descent into darkness around sentence 18, recovery toward the end. If you fed in an actual novel (sentence by sentence), you'd see the emotional structure that the author built. Kurt Vonnegut famously sketched story shapes by hand -- "man in hole" (happy, falls into trouble, climbs out), "boy meets girl" (rises, falls, rises). This visualization generates those shapes automatically from the text itself. ## Text as color: every character a pixel Here's a purely aesthetic approach: map each character in a text to a color and draw them as a grid of tiny colored rectangles. No semantic analysis, no word counting. Just the visual pattern of the character stream. Different languages produce different patterns because their character frequency distributions differ. English has lots of 'e', 't', 'a'. German has lots of 'e', 'n', 'r' plus umlauts. The visual fingerprint of a language emerges from character-level coloring. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 500; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); function charToColor(ch) { const code = ch.charCodeAt(0); if (ch === ' ') return { h: 0, s: 0, l: 5 }; // spaces: near-black if (ch === '\n') return { h: 0, s: 0, l: 3 }; // newlines: very dark if (/[aeiou]/.test(ch)) { // vowels: warm tones const vowelMap = { a: 0, e: 30, i: 50, o: 25, u: 40 }; return { h: vowelMap[ch] || 20, s: 60, l: 45 }; } if (/[a-z]/.test(ch)) { // consonants: cool tones const hue = 180 + ((code - 97) / 26) * 120; return { h: hue, s: 45, l: 35 }; } if (/[0-9]/.test(ch)) return { h: 60, s: 40, l: 30 }; // digits: yellow // punctuation: bright accents return { h: 300, s: 50, l: 50 }; } const text = `the quick brown fox jumps over the lazy dog and the cat sat on the mat while the rain fell softly on the old tin roof making patterns in the dust a bird sang somewhere in the distance its melody weaving through the afternoon shadows lengthened across the garden as the sun dipped below the horizon everything was still except for the wind rustling through the dry autumn leaves`; const chars = text.split(''); const cols = 70; const cellW = 700 / cols; const cellH = cellW; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 500); for (let i = 0; i < chars.length; i++) { const col = i % cols; const row = Math.floor(i / cols); const ch = chars[i].toLowerCase(); const color = charToColor(ch); ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`; ctx.fillRect(col * cellW, row * cellH, cellW - 0.5, cellH - 0.5); } ``` Vowels glow warm (reds and oranges). Consonants sit cool (blues and greens). Spaces are near-black gaps. Punctuation pops as bright magenta accents. The visual texture of English prose becomes a fabric of warm and cool patches, with the vowel-consonant rhythm creating a kind of weave pattern. Compare this to a language with fewer vowels (like Czech or Polish) and you'd see a cooler, more blue-green image. A language with lots of vowels (like Hawaiian or Italian) would glow warmer. This technique maps the *texture* of language, not its meaning. Two texts about completely different subjects in the same language will produce similar color patterns. Two translations of the same text into different languages will look very different. The visualization captures the phonetic DNA of the language itself. ## Reading level: measuring complexity The Flesch-Kincaid readability formula estimates what grade level a text is written at. It uses two inputs: average sentence length (words per sentence) and average syllable count per word. Longer sentences and longer words mean higher reading level. It's imperfect -- "antidisestablishmentarianism" is a long word but a 10-year-old knows it -- but as a rough metric it works surprisingly well. ```javascript function countSyllables(word) { word = word.toLowerCase().replace(/[^a-z]/g, ''); if (word.length <= 3) return 1; // simple heuristic: count vowel groups const vowelGroups = word.match(/[aeiouy]+/g); let count = vowelGroups ? vowelGroups.length : 1; // silent e if (word.endsWith('e') && count > 1) count--; // words ending in 'le' after consonant if (word.endsWith('le') && word.length > 2 && !/[aeiouy]/.test(word[word.length - 3])) { count++; } return Math.max(1, count); } function fleschKincaid(text) { const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); const words = text.replace(/[^a-z\s]/gi, '').split(/\s+/).filter(w => w.length > 0); const totalSentences = sentences.length; const totalWords = words.length; const totalSyllables = words.reduce((sum, w) => sum + countSyllables(w), 0); const avgSentenceLen = totalWords / totalSentences; const avgSyllables = totalSyllables / totalWords; // Flesch-Kincaid Grade Level const grade = 0.39 * avgSentenceLen + 11.8 * avgSyllables - 15.59; return { grade: Math.max(0, grade), avgSentenceLen, avgSyllables }; } ``` A children's book might score grade 3-4. A newspaper article scores 8-10. Academic papers score 12-16. Feed in the text of a novel chapter by chapter and plot the grade level over the book -- you can see where the author simplifies (action scenes, dialogue) and where they get technical (exposition, world-building). The complexity map of a text is another kind of rhythm, related to but distinct from the sentiment arc. ## Word clouds: done right Word clouds are the most common text visualization and also the most criticized. They're often random, ugly, and hard to read. But the concept -- size proportional to frequency -- is sound. The execution just needs care. ```javascript const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // top 30 content words from a hypothetical text const wordData = [ ['ocean', 45], ['wave', 38], ['ship', 32], ['storm', 28], ['captain', 25], ['crew', 22], ['wind', 20], ['sail', 19], ['harbor', 17], ['coast', 16], ['night', 15], ['stars', 14], ['compass', 13], ['journey', 12], ['fog', 11], ['island', 10], ['reef', 10], ['current', 9], ['horizon', 9], ['depth', 8], ['anchor', 8], ['rope', 7], ['deck', 7], ['mast', 6], ['whale', 6], ['lighthouse', 5], ['tide', 5], ['salt', 5], ['port', 4], ['bow', 4] ]; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 800, 600); const maxFreq = wordData[0][1]; // spiral placement: try positions along a spiral, accept if no overlap const placed = []; for (const [word, freq] of wordData) { const fontSize = 14 + (freq / maxFreq) * 48; ctx.font = `${Math.floor(fontSize)}px monospace`; const metrics = ctx.measureText(word); const wordW = metrics.width; const wordH = fontSize; // spiral outward from center until we find a free spot let px = 0, py = 0; let found = false; for (let t = 0; t < 500; t++) { const angle = t * 0.15; const radius = t * 0.8; const testX = 400 + Math.cos(angle) * radius - wordW / 2; const testY = 300 + Math.sin(angle) * radius + wordH / 3; // check overlap with placed words let overlaps = false; for (const p of placed) { if (testX < p.x + p.w + 4 && testX + wordW + 4 > p.x && testY - wordH < p.y && testY > p.y - p.h) { overlaps = true; break; } } if (!overlaps) { px = testX; py = testY; found = true; break; } } if (found) { const hue = 180 + (freq / maxFreq) * 60; const lightness = 30 + (freq / maxFreq) * 25; ctx.font = `${Math.floor(fontSize)}px monospace`; ctx.fillStyle = `hsl(${hue}, 50%, ${lightness}%)`; ctx.fillText(word, px, py); placed.push({ x: px, y: py, w: wordW, h: wordH }); } } ``` The spiral placement algorithm starts at the center and works outward, testing each position along an Archimedean spiral until it finds a spot that doesn't overlap any previously placed word. High-frequency words are placed first (they're biggest and need the most room), so they end up near the center. Low-frequency words fill the gaps around the edges. The result is compact and organized, not the random scatter that gives word clouds a bad name. The key improvement over naive word clouds: overlap detection. Without it, words stack on top of each other and become unreadable. With it, every word is visible and the size hierarchy is clean. It's still a word cloud -- purists will complain that exact comparison between similarly-sized words is hard -- but for creative coding, the visual impact is solid. ## Comparing two texts Side-by-side comparison reveals how differently two texts use language. Same visualization, different data. The contrast tells the story. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 400; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // two fake texts: one technical, one poetic const techWords = [ ['function', 22], ['data', 18], ['array', 15], ['loop', 14], ['variable', 12], ['code', 11], ['return', 10], ['string', 9], ['object', 9], ['method', 8], ['index', 7], ['value', 7], ['error', 6], ['type', 6], ['class', 5] ]; const poetWords = [ ['moon', 20], ['river', 16], ['shadow', 14], ['silence', 13], ['dream', 12], ['light', 11], ['wind', 10], ['stone', 9], ['rain', 9], ['flower', 8], ['dawn', 7], ['bird', 7], ['song', 6], ['ocean', 6], ['star', 5] ]; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 400); const maxFreq = 22; function drawWordBars(words, startX, hue, label) { ctx.fillStyle = 'rgba(180, 180, 200, 0.5)'; ctx.font = '12px monospace'; ctx.textAlign = 'center'; ctx.fillText(label, startX + 190, 25); for (let i = 0; i < words.length; i++) { const [word, freq] = words[i]; const y = 40 + i * 23; const barW = (freq / maxFreq) * 250; ctx.fillStyle = `hsla(${hue}, 50%, 45%, 0.7)`; ctx.fillRect(startX, y, barW, 18); ctx.fillStyle = 'rgba(200, 200, 220, 0.6)'; ctx.font = '10px monospace'; ctx.textAlign = 'left'; ctx.fillText(word, startX + barW + 6, y + 13); } } drawWordBars(techWords, 40, 200, 'Technical Text'); drawWordBars(poetWords, 500, 320, 'Poetry'); ``` Two horizontal bar charts, side by side. The technical text is dominated by "function", "data", "array" -- the vocabulary of code. The poetry is dominated by "moon", "river", "shadow" -- the vocabulary of imagery. The bar lengths make the frequency differences scannable at a glance. The hue difference (cool blue for tech, warm magenta for poetry) reinforces the thematic contrast. This is the simplest form of text comparison but it's effective. You could go deeper: shared words highlighted in a third color, unique words per text emphasized, frequency ratios computed. But the basic side-by-side bar chart already communicates the core insight -- these two texts live in completely different vocabulary spaces. ## Markov chain text generation Allez, time for something different. Instead of *analyzing* text, let's *generate* it. A Markov chain builds a probability model from a source text: for each word, what words are likely to follow it? Then you generate new text by picking words according to those probabilities. The output has the same statistical texture as the input -- similar word patterns, similar rhythm -- but it's nonsense. Beautiful, evocative nonsense. ```javascript function buildMarkovChain(text, order) { const words = text.split(/\s+/); const chain = {}; for (let i = 0; i < words.length - order; i++) { const key = words.slice(i, i + order).join(' '); const next = words[i + order]; if (!chain[key]) chain[key] = []; chain[key].push(next); } return chain; } function generateText(chain, order, length) { const keys = Object.keys(chain); let current = keys[Math.floor(Math.random() * keys.length)]; const output = current.split(' '); for (let i = 0; i < length; i++) { const options = chain[current]; if (!options || options.length === 0) { current = keys[Math.floor(Math.random() * keys.length)]; continue; } const next = options[Math.floor(Math.random() * options.length)]; output.push(next); const words = output.slice(-order); current = words.join(' '); } return output.join(' '); } // example usage: const source = `the sea was calm and the ship sailed slowly through the dark water the stars reflected in the waves and the wind was gentle the captain stood on the deck watching the horizon where clouds gathered slowly the sea grew rough and the waves crashed against the hull the wind howled through the rigging and the crew worked to secure the sails`; const chain = buildMarkovChain(source, 2); const generated = generateText(chain, 2, 50); ``` Order 1 produces word salad -- each word connects to any word that ever followed it in the source, so the output jumps around randomly. Order 2 is the sweet spot for creative use: it captures two-word phrases, so the output has local coherence ("the sea", "the wind") but global incoherence (sentences don't make sense as a whole). Order 3 starts reproducing entire source sentences because three-word sequences in a short source text are often unique. For creative coding, Markov-generated text is material. You can visualize the generation process itself: draw each word as it's selected, color-coded by which source sentence it came from. The generated text becomes a visual patchwork of fragments from the original, reassembled into new patterns. The source text as DNA, the generated text as a mutant offspring. ## Visualizing generated text with provenance ```javascript const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 400; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // generate text and track which source position each word came from function generateWithProvenance(chain, order, length) { const keys = Object.keys(chain); let current = keys[Math.floor(Math.random() * keys.length)]; const output = []; const startWords = current.split(' '); for (const w of startWords) { output.push({ word: w, source: Math.random() }); } for (let i = 0; i < length; i++) { const options = chain[current]; if (!options || options.length === 0) { current = keys[Math.floor(Math.random() * keys.length)]; continue; } const idx = Math.floor(Math.random() * options.length); const next = options[idx]; output.push({ word: next, source: idx / options.length }); const words = output.slice(-order).map(o => o.word); current = words.join(' '); } return output; } const source = `the sea was calm and the ship sailed through the dark water the stars reflected in the waves and the wind was gentle the captain stood on the deck watching the horizon where clouds gathered slowly`; const chain = buildMarkovChain(source, 2); const generated = generateWithProvenance(chain, 2, 60); ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 800, 400); let x = 20; let y = 40; const lineHeight = 28; ctx.font = '14px monospace'; for (const item of generated) { const metrics = ctx.measureText(item.word + ' '); if (x + metrics.width > 780) { x = 20; y += lineHeight; } // color from source position: different hues for different "origins" const hue = item.source * 280 + 120; ctx.fillStyle = `hsl(${hue}, 50%, 50%)`; ctx.fillText(item.word, x, y); x += metrics.width; } ``` Each word is colored by where it came from in the probability table. Words that had many possible successors (high entropy) get one color. Words that came from a unique, deterministic transition get another. The color pattern reveals the structure of the Markov chain itself -- predictable passages are uniform in color, unpredictable junctions create color shifts. You're visualizing not just the text but the *generation process*. ## Creative exercise: text portrait of two texts Time to put it together. Take two short texts with different styles. Compute word frequency, average sentence length, and sentiment for each. Create a side-by-side visual portrait: a colored bar for each sentence (height from sentence length, color from sentiment), with the most frequent content words arranged around it. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 500; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // two contrasting texts const textA = `The sun rose over the mountain and light poured into the valley. Birds sang in the trees and the river sparkled. It was a beautiful morning and everything felt alive. The children ran through the meadow laughing. Flowers bloomed everywhere in brilliant colors. A gentle breeze carried the scent of pine. Life was good and the world was at peace.`; const textB = `The factory stood silent in the grey rain. Rust crept along the walls and the windows were broken. Nobody came here anymore. The machines had stopped years ago and dust covered everything. Wind whistled through the cracks. A dog wandered through the empty parking lot alone. The only sound was water dripping from the rusted pipes.`; function analyzeText(text) { const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); return sentences.map(s => { const words = s.trim().split(/\s+/); let score = 0; for (const w of words) { const lower = w.toLowerCase().replace(/[^a-z]/g, ''); if (sentiment[lower]) score += sentiment[lower]; } return { length: words.length, sentiment: score, text: s.trim() }; }); } const analysisA = analyzeText(textA); const analysisB = analyzeText(textB); ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 500); function drawTextPortrait(analysis, startX, label) { ctx.fillStyle = 'rgba(180, 180, 200, 0.5)'; ctx.font = '12px monospace'; ctx.textAlign = 'center'; ctx.fillText(label, startX + 180, 30); const maxLen = 20; const barWidth = 360 / analysis.length - 3; for (let i = 0; i < analysis.length; i++) { const s = analysis[i]; const x = startX + i * (barWidth + 3); const barH = (Math.min(s.length, maxLen) / maxLen) * 300; const y = 420 - barH; // sentiment -> color: positive = warm amber, negative = cool blue const sentNorm = (s.sentiment + 6) / 12; const hue = sentNorm * 40 + (1 - sentNorm) * 220; const lightness = 25 + Math.abs(s.sentiment) * 5; ctx.fillStyle = `hsl(${hue}, 55%, ${lightness}%)`; ctx.fillRect(x, y, barWidth, barH); // sentence length number ctx.fillStyle = 'rgba(160, 170, 190, 0.4)'; ctx.font = '9px monospace'; ctx.textAlign = 'center'; ctx.fillText(s.length, x + barWidth / 2, 440); } } drawTextPortrait(analysisA, 30, 'Sunny Valley'); drawTextPortrait(analysisB, 480, 'Abandoned Factory'); ``` The sunny valley text has warm amber bars -- positive sentiment throughout. The abandoned factory has cool blue bars -- negative sentiment. The bar heights show sentence length variation: the valley text has moderate, even sentences; the factory text has one very short sentence ("Nobody came here anymore.") that creates a visual dip. The two portraits are visually distinct even if you can't read the words. The color temperature alone tells you: one text is happy, one is not. See where this is going? :-) Text is infinite creative material. Every book, every speech, every tweet, every log file has measurable properties that map to visual channels. Word frequency drives size. Sentiment drives color. Sentence length drives rhythm. Character distribution drives texture. And Markov chains can generate new text with the statistical DNA of the original. Data art from text isn't limited to word clouds -- it's an entire visual language for the written word. The techniques from episodes 82-85 all apply here. The `map()` function from episode 82 converts word counts to pixel sizes. The log scaling from episode 83 tames Zipf's power law. The temporal layout from episode 84 works for plotting sentiment over a story. The network visualization from episode 85 could show word co-occurrence graphs. Text analysis plugs directly into the data-to-visuals pipeline we've been building. ## 't Komt erop neer... - Text is unstructured data. Before you can visualize it, you have to measure it -- word frequency, sentence length, character distribution, sentiment scores. These measurements turn prose into numbers your canvas can work with - Word frequency analysis counts each word's occurrences and sorts by count. Stop words ("the", "and", "is") dominate every English text -- filter them to reveal the content words that characterize what the text is about - Zipf's law: in any natural language, word frequency times rank is roughly constant. This power law creates extreme distributions (a few very common words, a long tail of rare ones) that require log scaling for visual display - Sentence length variation reveals writing rhythm. Short sentences mean tension. Long sentences mean exposition. Plotting sentence lengths as a bar chart turns prose rhythm into visible pattern -- you can see Hemingway vs Faulkner without reading a word - Sentiment analysis scores text as positive or negative using dictionary lookup (AFINN lexicon: -5 to +5 per word). Crude but effective for creative coding. Plot sentiment per sentence across a story to see the emotional arc -- the shape of the narrative's mood - Character-to-color mapping treats each letter as a pixel. Vowels get warm tones, consonants get cool tones, spaces stay dark. The resulting grid shows the phonetic texture of the language itself -- different languages produce different visual patterns from their character frequency distributions - Flesch-Kincaid readability scores grade level from average sentence length and syllable count. Visualizing reading level across a text shows where the author simplifies and where they get technical - Word clouds work when you use spiral placement with overlap detection. Place high-frequency words first at the center, let smaller words fill gaps outward. The overlap test is what separates readable word clouds from visual garbage - Text comparison works through side-by-side bar charts of word frequency. Two texts in different domains (technical vs poetic) occupy completely different vocabulary spaces -- the contrast is immediately visible in the bar lengths and word labels - Markov chains build probability models from source text (what words follow what other words) and generate new text with the same statistical properties. Order 2 hits the creative sweet spot: locally coherent phrases, globally nonsensical. The generated text carries the statistical DNA of the source - Visualizing Markov generation with provenance tracking (coloring each word by its origin in the probability table) reveals the structure of the generation process itself -- predictable passages vs high-entropy junctions Sallukes! Thanks for reading. X @femdev |
| title | Learn Creative Coding (#86) - Text as Data: Analyzing and Visualizing Language |
| author | femdev |
| permlink | learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language |
| json metadata | {"tags": ["stem", "stemsocial", "steemstem", "programming", "creativecoding"], "app": "hiveblog/0.1", "format": "markdown", "image": ["https://images.hive.blog/DQmcX6yi9Uoqxh5UyHPgVosRfwKDaeR2JFKby1JqADcAjbD/cc-banner-amber.png"]} |
| parent author | |
| parent permlink | hive-196387 |
| Transaction Info | Block #107035674/Trx ca077bf257ec4dfc4c8a75651960b3728c40e551 |
View Raw JSON Data
{
"op": [
"comment",
{
"body": "# Learn Creative Coding (#86) - Text as Data: Analyzing and Visualizing Language\n\n\n\nLast episode we built network visualizations -- force-directed layouts, community detection, adjacency matrices, arc diagrams, interactive hover highlighting, edge bundling. We turned structural relationships into visual patterns. Nodes and edges, clusters and bridges, the social architecture of data made visible. But all the data we've worked with so far in this arc -- geographic coordinates, timestamps, network connections -- has been inherently numeric. Numbers map naturally to visual properties: position, size, color, angle. The mapping is direct.\n\nText is different. Text is messy. A paragraph of English isn't a number. A word isn't a coordinate. Before you can visualize text, you have to *measure* it -- turn words and sentences into quantities that your canvas can work with. Word frequency, sentence length, character distribution, sentiment scores. Once you extract those measurements, text becomes data like any other. And the patterns hiding in language turn out to be surprisingly beautiful.\n\nThis episode is about treating text as raw material for creative coding. We'll count words (and discover Zipf's law along the way), measure sentence rhythm, score sentiment with a dictionary lookup, map characters and words to colors, compare two texts side by side, and generate new text with Markov chains. We parsed structured data files back in episode 81 -- CSV rows and JSON objects with neat fields. Text is the unstructured cousin: no columns, no types, just a stream of characters that your code has to make sense of.\n\n## Word frequency: the shape of language\n\nThe most basic measurement of text: how often does each word appear? Count every word, sort by frequency, and you've got a profile of what the text is *about*. The most frequent words in any English text are always the same: \"the\", \"a\", \"is\", \"and\", \"of\", \"to\". These are called stop words -- they're structural, not meaningful. Filter them out and the content words that remain tell you the subject matter. A novel about whales will have \"whale\", \"sea\", \"captain\" near the top. A tech blog post will have \"function\", \"data\", \"code\".\n\n```javascript\nfunction countWords(text) {\n const words = text.toLowerCase()\n .replace(/[^a-z\\s]/g, '')\n .split(/\\s+/)\n .filter(w => w.length > 0);\n\n const counts = {};\n for (const word of words) {\n counts[word] = (counts[word] || 0) + 1;\n }\n\n return Object.entries(counts)\n .sort((a, b) => b[1] - a[1]);\n}\n\nconst sampleText = `The quick brown fox jumps over the lazy dog.\nThe dog barked at the fox. The fox ran away from the dog.\nA cat watched the dog chase the fox from the garden wall.\nThe garden was quiet after the fox and the dog left.`;\n\nconst freq = countWords(sampleText);\n// [[\"the\", 10], [\"dog\", 4], [\"fox\", 4], [\"the\", ...], ...]\n```\n\nTen occurrences of \"the\" in four sentences. Four each for \"dog\" and \"fox\". That's already telling you something -- this text is about a dog and a fox, which of course it is, but the algorithm doesn't know that. It just counted.\n\nThe `replace(/[^a-z\\s]/g, '')` strip is crude but effective -- it removes punctuation, digits, and special characters, leaving only lowercase letters and spaces. A proper tokenizer would handle apostrophes (\"don't\" should be one word, not \"don\" and \"t\"), hyphens (\"well-known\"), and unicode. For creative coding purposes, the crude version usually works fine.\n\n## Zipf's law: the universal pattern\n\nHere's something wild. If you take any sufficiently long text in any natural language and plot word frequency on a log-log scale, you get a straight line. The most frequent word appears roughly twice as often as the second most frequent, three times as often as the third, and so on. Frequency times rank equals a constant. This is Zipf's law, and it holds for English, French, Japanese, Arabic, ancient Greek -- every natural language ever studied.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 500;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// generate synthetic word frequencies following Zipf's law\n// (in real use, feed in actual counted words from a text)\nconst numWords = 200;\nconst zipfFreqs = [];\nconst maxFreq = 5000;\nfor (let rank = 1; rank <= numWords; rank++) {\n // zipf: freq = C / rank^s, where s is close to 1\n const freq = maxFreq / Math.pow(rank, 1.07);\n zipfFreqs.push({ rank, freq });\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 700, 500);\n\n// log-log plot\nconst logMaxRank = Math.log10(numWords);\nconst logMaxFreq = Math.log10(maxFreq);\n\nfor (const { rank, freq } of zipfFreqs) {\n const logRank = Math.log10(rank);\n const logFreq = Math.log10(freq);\n\n const x = 60 + (logRank / logMaxRank) * 580;\n const y = 460 - (logFreq / logMaxFreq) * 420;\n\n ctx.beginPath();\n ctx.arc(x, y, 3, 0, Math.PI * 2);\n ctx.fillStyle = 'rgba(120, 180, 255, 0.6)';\n ctx.fill();\n}\n\n// reference line (perfect Zipf)\nctx.beginPath();\nctx.moveTo(60, 40);\nctx.lineTo(640, 460);\nctx.strokeStyle = 'rgba(255, 150, 100, 0.3)';\nctx.lineWidth = 1;\nctx.stroke();\n```\n\nThe dots fall on a straight line. That straight line on a log-log plot means the relationship is a power law -- a mathematical signature that appears in city populations, earthquake magnitudes, website traffic, income distributions, and apparently every language humans have ever spoken. Nobody fully agrees on *why* Zipf's law holds for language. It's one of those patterns that seem too universal to be coincidence but too mysterious to explain from first principles.\n\nFor creative coding, Zipf's law means word frequency distributions always have the same shape: a few very common words, a long tail of rare words. Your visualization has to handle that extreme range. Linear mapping won't work -- the top 5 words will dominate and everything else will be invisible. Log scaling (like we used for population density in episode 83) fixes that.\n\n## Stop words and content filtering\n\nThose ultra-frequent words -- \"the\", \"and\", \"is\", \"of\", \"to\", \"a\", \"in\", \"that\" -- are the stop words. They're grammatically essential but semantically empty. For most text visualization, you want to remove them so the meaningful words shine through.\n\n```javascript\nconst stopWords = new Set([\n 'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were',\n 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did',\n 'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'can',\n 'of', 'to', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as',\n 'into', 'through', 'during', 'before', 'after', 'above', 'below',\n 'between', 'out', 'off', 'over', 'under', 'again', 'further',\n 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how',\n 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other',\n 'some', 'such', 'no', 'not', 'only', 'own', 'same', 'so', 'than',\n 'too', 'very', 'just', 'because', 'about', 'up', 'down',\n 'it', 'its', 'he', 'she', 'they', 'them', 'his', 'her', 'their',\n 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'us', 'our',\n 'you', 'your', 'who', 'which', 'what'\n]);\n\nfunction contentWords(text) {\n return text.toLowerCase()\n .replace(/[^a-z\\s]/g, '')\n .split(/\\s+/)\n .filter(w => w.length > 1 && !stopWords.has(w));\n}\n\nfunction contentWordFrequency(text) {\n const words = contentWords(text);\n const counts = {};\n for (const word of words) {\n counts[word] = (counts[word] || 0) + 1;\n }\n return Object.entries(counts).sort((a, b) => b[1] - a[1]);\n}\n```\n\nNow instead of \"the, a, is, and, of\" you get the actual subject words. Feed in a speech by Martin Luther King and you'll get \"dream\", \"freedom\", \"nation\", \"justice\" at the top. Feed in a cooking recipe and you'll get \"butter\", \"flour\", \"minutes\", \"stir\". The content filter is the difference between seeing the skeleton of English grammar (same for every text) and seeing the fingerprint of *this particular* text.\n\nThere's a creative argument for keeping stop words, though. Their frequency patterns reveal writing style, not content. One author might use \"however\" constantly while another never does. The ratio of \"I\" to \"we\" tells you something about perspective. If you're comparing *how* two texts are written rather than *what* they're about, the stop words are the signal, not the noise.\n\n## Sentence length: the rhythm of prose\n\nEvery writer has a rhythm. Hemingway wrote short, punchy sentences. Faulkner wrote sentences that stretched across half a page, nesting clause inside clause inside clause, circling back to pick up threads that seemed abandoned paragraphs ago. Visualizing sentence length turns that rhythmic fingerprint into a visible pattern.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 300;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\nfunction getSentenceLengths(text) {\n // split on . ! ? (simplified -- doesn't handle abbreviations like \"Dr.\" or \"U.S.\")\n const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);\n return sentences.map(s => s.trim().split(/\\s+/).length);\n}\n\n// simulate two different writing styles\nconst shortStyle = []; // hemingway-ish\nconst longStyle = []; // faulkner-ish\n\nfor (let i = 0; i < 40; i++) {\n shortStyle.push(4 + Math.floor(Math.random() * 8));\n longStyle.push(15 + Math.floor(Math.random() * 30));\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 300);\n\nconst barWidth = 900 / 40 - 2;\nconst maxLen = Math.max(...shortStyle, ...longStyle);\n\n// top row: short style\nfor (let i = 0; i < 40; i++) {\n const h = (shortStyle[i] / maxLen) * 120;\n ctx.fillStyle = 'rgba(100, 200, 180, 0.6)';\n ctx.fillRect(i * (barWidth + 2) + 1, 130 - h, barWidth, h);\n}\n\n// bottom row: long style\nfor (let i = 0; i < 40; i++) {\n const h = (longStyle[i] / maxLen) * 120;\n ctx.fillStyle = 'rgba(200, 120, 180, 0.6)';\n ctx.fillRect(i * (barWidth + 2) + 1, 170, barWidth, h);\n}\n```\n\nTwo writers, same number of sentences. The top row is low and uniform -- short, choppy, rhythmically tight. The bottom row is tall and varied -- long, sprawling, rhythmically loose. You can see the writing style without reading a single word. The bar chart IS the prose rhythm, translated from temporal flow into spatial pattern.\n\nThis is one of my favuorite text visualizations because it captures something that's genuinely hard to articulate in words. You *feel* writing rhythm when you read, but you can't easily describe it. \"Hemingway writes short sentences\" is true but doesn't tell you the *pattern* -- how his short sentences cluster, where the occasional long one breaks the rhythm for emphasis. The visualization shows all of that at once.\n\n## Sentiment analysis: positive and negative\n\nSentiment analysis rates text as positive, negative, or neutral. The simplest approach: use a word-level dictionary where each word has a pre-assigned score. The AFINN lexicon gives scores from -5 (very negative) to +5 (very positive) for about 2,477 English words. \"Love\" is +3. \"Hate\" is -3. \"Disaster\" is -3. \"Excellent\" is +3. Most words aren't in the dictionary at all -- they're neutral.\n\n```javascript\n// simplified AFINN-style sentiment dictionary\nconst sentiment = {\n 'love': 3, 'happy': 3, 'joy': 3, 'great': 3, 'excellent': 3,\n 'wonderful': 4, 'beautiful': 3, 'amazing': 4, 'good': 2, 'nice': 2,\n 'like': 1, 'best': 3, 'brilliant': 4, 'perfect': 3, 'hope': 2,\n 'win': 3, 'won': 3, 'success': 2, 'smile': 2, 'laugh': 1,\n 'hate': -3, 'terrible': -3, 'awful': -3, 'bad': -2, 'worst': -3,\n 'ugly': -2, 'stupid': -2, 'fail': -2, 'failed': -2, 'disaster': -3,\n 'horrible': -3, 'angry': -2, 'sad': -2, 'pain': -2, 'fear': -2,\n 'kill': -3, 'die': -2, 'death': -2, 'war': -2, 'destroy': -3,\n 'wrong': -2, 'broken': -1, 'hurt': -2, 'lost': -1, 'miss': -1\n};\n\nfunction scoreSentiment(text) {\n const words = text.toLowerCase().replace(/[^a-z\\s]/g, '').split(/\\s+/);\n let total = 0;\n let scored = 0;\n\n for (const word of words) {\n if (sentiment[word] !== undefined) {\n total += sentiment[word];\n scored++;\n }\n }\n\n return { total, scored, average: scored > 0 ? total / scored : 0 };\n}\n```\n\nThis is crude. It doesn't understand negation (\"not happy\" should be negative but scores positive because \"happy\" is +3 and \"not\" isn't scored). It doesn't understand sarcasm, context, or intensity modifiers (\"really happy\" vs \"happy\"). But for creative coding, crude works. You're not building a production sentiment classifier -- you're extracting a signal that drives visual output. The imperfections add character. A text that's mostly positive with occasional negative spikes creates a visual rhythm that's more interesting than a perfectly calibrated flat score.\n\n## Visualizing sentiment over a story\n\nThe real magic happens when you score sentiment *per sentence* and plot it across a text. The emotional arc of a story becomes visible. Happy beginning, dark middle, triumphant ending? You'll see it as a color gradient shifting from warm to cold and back to warm.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 250;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// fake story: 30 sentences with an emotional arc\n// positive start, dip into negative, positive ending\nconst storyScores = [];\nfor (let i = 0; i < 30; i++) {\n const t = i / 29;\n // arc shape: starts positive, dips negative at 60%, recovers\n const arc = Math.sin(t * Math.PI * 2 - Math.PI * 0.3) * 2;\n const noise = (Math.random() - 0.5) * 1.5;\n storyScores.push(arc + noise);\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 250);\n\nconst barW = 900 / 30 - 2;\n\nfor (let i = 0; i < storyScores.length; i++) {\n const score = storyScores[i];\n const x = i * (barW + 2) + 1;\n\n // positive = warm amber, negative = cool blue\n const normalized = (score + 4) / 8; // roughly -4 to +4 -> 0 to 1\n const hue = normalized * 40 + (1 - normalized) * 220;\n const lightness = 25 + Math.abs(score) * 8;\n\n const barH = Math.abs(score) * 20 + 5;\n const y = score >= 0 ? 125 - barH : 125;\n\n ctx.fillStyle = `hsl(${hue}, 55%, ${lightness}%)`;\n ctx.fillRect(x, y, barW, barH);\n}\n\n// center line\nctx.beginPath();\nctx.moveTo(0, 125);\nctx.lineTo(900, 125);\nctx.strokeStyle = 'rgba(80, 90, 110, 0.3)';\nctx.lineWidth = 1;\nctx.stroke();\n```\n\nWarm bars above the line: positive sentiment. Cool bars below: negative. The emotional arc is visible -- optimistic opening, descent into darkness around sentence 18, recovery toward the end. If you fed in an actual novel (sentence by sentence), you'd see the emotional structure that the author built. Kurt Vonnegut famously sketched story shapes by hand -- \"man in hole\" (happy, falls into trouble, climbs out), \"boy meets girl\" (rises, falls, rises). This visualization generates those shapes automatically from the text itself.\n\n## Text as color: every character a pixel\n\nHere's a purely aesthetic approach: map each character in a text to a color and draw them as a grid of tiny colored rectangles. No semantic analysis, no word counting. Just the visual pattern of the character stream. Different languages produce different patterns because their character frequency distributions differ. English has lots of 'e', 't', 'a'. German has lots of 'e', 'n', 'r' plus umlauts. The visual fingerprint of a language emerges from character-level coloring.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 500;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\nfunction charToColor(ch) {\n const code = ch.charCodeAt(0);\n\n if (ch === ' ') return { h: 0, s: 0, l: 5 }; // spaces: near-black\n if (ch === '\\n') return { h: 0, s: 0, l: 3 }; // newlines: very dark\n if (/[aeiou]/.test(ch)) {\n // vowels: warm tones\n const vowelMap = { a: 0, e: 30, i: 50, o: 25, u: 40 };\n return { h: vowelMap[ch] || 20, s: 60, l: 45 };\n }\n if (/[a-z]/.test(ch)) {\n // consonants: cool tones\n const hue = 180 + ((code - 97) / 26) * 120;\n return { h: hue, s: 45, l: 35 };\n }\n if (/[0-9]/.test(ch)) return { h: 60, s: 40, l: 30 }; // digits: yellow\n // punctuation: bright accents\n return { h: 300, s: 50, l: 50 };\n}\n\nconst text = `the quick brown fox jumps over the lazy dog and the cat sat on the mat\nwhile the rain fell softly on the old tin roof making patterns in the dust\na bird sang somewhere in the distance its melody weaving through the afternoon\nshadows lengthened across the garden as the sun dipped below the horizon\neverything was still except for the wind rustling through the dry autumn leaves`;\n\nconst chars = text.split('');\nconst cols = 70;\nconst cellW = 700 / cols;\nconst cellH = cellW;\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 700, 500);\n\nfor (let i = 0; i < chars.length; i++) {\n const col = i % cols;\n const row = Math.floor(i / cols);\n const ch = chars[i].toLowerCase();\n const color = charToColor(ch);\n\n ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`;\n ctx.fillRect(col * cellW, row * cellH, cellW - 0.5, cellH - 0.5);\n}\n```\n\nVowels glow warm (reds and oranges). Consonants sit cool (blues and greens). Spaces are near-black gaps. Punctuation pops as bright magenta accents. The visual texture of English prose becomes a fabric of warm and cool patches, with the vowel-consonant rhythm creating a kind of weave pattern. Compare this to a language with fewer vowels (like Czech or Polish) and you'd see a cooler, more blue-green image. A language with lots of vowels (like Hawaiian or Italian) would glow warmer.\n\nThis technique maps the *texture* of language, not its meaning. Two texts about completely different subjects in the same language will produce similar color patterns. Two translations of the same text into different languages will look very different. The visualization captures the phonetic DNA of the language itself.\n\n## Reading level: measuring complexity\n\nThe Flesch-Kincaid readability formula estimates what grade level a text is written at. It uses two inputs: average sentence length (words per sentence) and average syllable count per word. Longer sentences and longer words mean higher reading level. It's imperfect -- \"antidisestablishmentarianism\" is a long word but a 10-year-old knows it -- but as a rough metric it works surprisingly well.\n\n```javascript\nfunction countSyllables(word) {\n word = word.toLowerCase().replace(/[^a-z]/g, '');\n if (word.length <= 3) return 1;\n\n // simple heuristic: count vowel groups\n const vowelGroups = word.match(/[aeiouy]+/g);\n let count = vowelGroups ? vowelGroups.length : 1;\n\n // silent e\n if (word.endsWith('e') && count > 1) count--;\n // words ending in 'le' after consonant\n if (word.endsWith('le') && word.length > 2 && !/[aeiouy]/.test(word[word.length - 3])) {\n count++;\n }\n\n return Math.max(1, count);\n}\n\nfunction fleschKincaid(text) {\n const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);\n const words = text.replace(/[^a-z\\s]/gi, '').split(/\\s+/).filter(w => w.length > 0);\n\n const totalSentences = sentences.length;\n const totalWords = words.length;\n const totalSyllables = words.reduce((sum, w) => sum + countSyllables(w), 0);\n\n const avgSentenceLen = totalWords / totalSentences;\n const avgSyllables = totalSyllables / totalWords;\n\n // Flesch-Kincaid Grade Level\n const grade = 0.39 * avgSentenceLen + 11.8 * avgSyllables - 15.59;\n return { grade: Math.max(0, grade), avgSentenceLen, avgSyllables };\n}\n```\n\nA children's book might score grade 3-4. A newspaper article scores 8-10. Academic papers score 12-16. Feed in the text of a novel chapter by chapter and plot the grade level over the book -- you can see where the author simplifies (action scenes, dialogue) and where they get technical (exposition, world-building). The complexity map of a text is another kind of rhythm, related to but distinct from the sentiment arc.\n\n## Word clouds: done right\n\nWord clouds are the most common text visualization and also the most criticized. They're often random, ugly, and hard to read. But the concept -- size proportional to frequency -- is sound. The execution just needs care.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 800;\ncanvas.height = 600;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// top 30 content words from a hypothetical text\nconst wordData = [\n ['ocean', 45], ['wave', 38], ['ship', 32], ['storm', 28], ['captain', 25],\n ['crew', 22], ['wind', 20], ['sail', 19], ['harbor', 17], ['coast', 16],\n ['night', 15], ['stars', 14], ['compass', 13], ['journey', 12], ['fog', 11],\n ['island', 10], ['reef', 10], ['current', 9], ['horizon', 9], ['depth', 8],\n ['anchor', 8], ['rope', 7], ['deck', 7], ['mast', 6], ['whale', 6],\n ['lighthouse', 5], ['tide', 5], ['salt', 5], ['port', 4], ['bow', 4]\n];\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 800, 600);\n\nconst maxFreq = wordData[0][1];\n\n// spiral placement: try positions along a spiral, accept if no overlap\nconst placed = [];\n\nfor (const [word, freq] of wordData) {\n const fontSize = 14 + (freq / maxFreq) * 48;\n ctx.font = `${Math.floor(fontSize)}px monospace`;\n const metrics = ctx.measureText(word);\n const wordW = metrics.width;\n const wordH = fontSize;\n\n // spiral outward from center until we find a free spot\n let px = 0, py = 0;\n let found = false;\n\n for (let t = 0; t < 500; t++) {\n const angle = t * 0.15;\n const radius = t * 0.8;\n const testX = 400 + Math.cos(angle) * radius - wordW / 2;\n const testY = 300 + Math.sin(angle) * radius + wordH / 3;\n\n // check overlap with placed words\n let overlaps = false;\n for (const p of placed) {\n if (testX < p.x + p.w + 4 && testX + wordW + 4 > p.x &&\n testY - wordH < p.y && testY > p.y - p.h) {\n overlaps = true;\n break;\n }\n }\n\n if (!overlaps) {\n px = testX;\n py = testY;\n found = true;\n break;\n }\n }\n\n if (found) {\n const hue = 180 + (freq / maxFreq) * 60;\n const lightness = 30 + (freq / maxFreq) * 25;\n\n ctx.font = `${Math.floor(fontSize)}px monospace`;\n ctx.fillStyle = `hsl(${hue}, 50%, ${lightness}%)`;\n ctx.fillText(word, px, py);\n\n placed.push({ x: px, y: py, w: wordW, h: wordH });\n }\n}\n```\n\nThe spiral placement algorithm starts at the center and works outward, testing each position along an Archimedean spiral until it finds a spot that doesn't overlap any previously placed word. High-frequency words are placed first (they're biggest and need the most room), so they end up near the center. Low-frequency words fill the gaps around the edges. The result is compact and organized, not the random scatter that gives word clouds a bad name.\n\nThe key improvement over naive word clouds: overlap detection. Without it, words stack on top of each other and become unreadable. With it, every word is visible and the size hierarchy is clean. It's still a word cloud -- purists will complain that exact comparison between similarly-sized words is hard -- but for creative coding, the visual impact is solid.\n\n## Comparing two texts\n\nSide-by-side comparison reveals how differently two texts use language. Same visualization, different data. The contrast tells the story.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 400;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// two fake texts: one technical, one poetic\nconst techWords = [\n ['function', 22], ['data', 18], ['array', 15], ['loop', 14], ['variable', 12],\n ['code', 11], ['return', 10], ['string', 9], ['object', 9], ['method', 8],\n ['index', 7], ['value', 7], ['error', 6], ['type', 6], ['class', 5]\n];\n\nconst poetWords = [\n ['moon', 20], ['river', 16], ['shadow', 14], ['silence', 13], ['dream', 12],\n ['light', 11], ['wind', 10], ['stone', 9], ['rain', 9], ['flower', 8],\n ['dawn', 7], ['bird', 7], ['song', 6], ['ocean', 6], ['star', 5]\n];\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 400);\n\nconst maxFreq = 22;\n\nfunction drawWordBars(words, startX, hue, label) {\n ctx.fillStyle = 'rgba(180, 180, 200, 0.5)';\n ctx.font = '12px monospace';\n ctx.textAlign = 'center';\n ctx.fillText(label, startX + 190, 25);\n\n for (let i = 0; i < words.length; i++) {\n const [word, freq] = words[i];\n const y = 40 + i * 23;\n const barW = (freq / maxFreq) * 250;\n\n ctx.fillStyle = `hsla(${hue}, 50%, 45%, 0.7)`;\n ctx.fillRect(startX, y, barW, 18);\n\n ctx.fillStyle = 'rgba(200, 200, 220, 0.6)';\n ctx.font = '10px monospace';\n ctx.textAlign = 'left';\n ctx.fillText(word, startX + barW + 6, y + 13);\n }\n}\n\ndrawWordBars(techWords, 40, 200, 'Technical Text');\ndrawWordBars(poetWords, 500, 320, 'Poetry');\n```\n\nTwo horizontal bar charts, side by side. The technical text is dominated by \"function\", \"data\", \"array\" -- the vocabulary of code. The poetry is dominated by \"moon\", \"river\", \"shadow\" -- the vocabulary of imagery. The bar lengths make the frequency differences scannable at a glance. The hue difference (cool blue for tech, warm magenta for poetry) reinforces the thematic contrast.\n\nThis is the simplest form of text comparison but it's effective. You could go deeper: shared words highlighted in a third color, unique words per text emphasized, frequency ratios computed. But the basic side-by-side bar chart already communicates the core insight -- these two texts live in completely different vocabulary spaces.\n\n## Markov chain text generation\n\nAllez, time for something different. Instead of *analyzing* text, let's *generate* it. A Markov chain builds a probability model from a source text: for each word, what words are likely to follow it? Then you generate new text by picking words according to those probabilities. The output has the same statistical texture as the input -- similar word patterns, similar rhythm -- but it's nonsense. Beautiful, evocative nonsense.\n\n```javascript\nfunction buildMarkovChain(text, order) {\n const words = text.split(/\\s+/);\n const chain = {};\n\n for (let i = 0; i < words.length - order; i++) {\n const key = words.slice(i, i + order).join(' ');\n const next = words[i + order];\n\n if (!chain[key]) chain[key] = [];\n chain[key].push(next);\n }\n\n return chain;\n}\n\nfunction generateText(chain, order, length) {\n const keys = Object.keys(chain);\n let current = keys[Math.floor(Math.random() * keys.length)];\n const output = current.split(' ');\n\n for (let i = 0; i < length; i++) {\n const options = chain[current];\n if (!options || options.length === 0) {\n current = keys[Math.floor(Math.random() * keys.length)];\n continue;\n }\n\n const next = options[Math.floor(Math.random() * options.length)];\n output.push(next);\n\n const words = output.slice(-order);\n current = words.join(' ');\n }\n\n return output.join(' ');\n}\n\n// example usage:\nconst source = `the sea was calm and the ship sailed slowly through the\ndark water the stars reflected in the waves and the wind was gentle\nthe captain stood on the deck watching the horizon where clouds gathered\nslowly the sea grew rough and the waves crashed against the hull\nthe wind howled through the rigging and the crew worked to secure the sails`;\n\nconst chain = buildMarkovChain(source, 2);\nconst generated = generateText(chain, 2, 50);\n```\n\nOrder 1 produces word salad -- each word connects to any word that ever followed it in the source, so the output jumps around randomly. Order 2 is the sweet spot for creative use: it captures two-word phrases, so the output has local coherence (\"the sea\", \"the wind\") but global incoherence (sentences don't make sense as a whole). Order 3 starts reproducing entire source sentences because three-word sequences in a short source text are often unique.\n\nFor creative coding, Markov-generated text is material. You can visualize the generation process itself: draw each word as it's selected, color-coded by which source sentence it came from. The generated text becomes a visual patchwork of fragments from the original, reassembled into new patterns. The source text as DNA, the generated text as a mutant offspring.\n\n## Visualizing generated text with provenance\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 800;\ncanvas.height = 400;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// generate text and track which source position each word came from\nfunction generateWithProvenance(chain, order, length) {\n const keys = Object.keys(chain);\n let current = keys[Math.floor(Math.random() * keys.length)];\n const output = [];\n\n const startWords = current.split(' ');\n for (const w of startWords) {\n output.push({ word: w, source: Math.random() });\n }\n\n for (let i = 0; i < length; i++) {\n const options = chain[current];\n if (!options || options.length === 0) {\n current = keys[Math.floor(Math.random() * keys.length)];\n continue;\n }\n\n const idx = Math.floor(Math.random() * options.length);\n const next = options[idx];\n output.push({ word: next, source: idx / options.length });\n\n const words = output.slice(-order).map(o => o.word);\n current = words.join(' ');\n }\n\n return output;\n}\n\nconst source = `the sea was calm and the ship sailed through the dark water\nthe stars reflected in the waves and the wind was gentle the captain\nstood on the deck watching the horizon where clouds gathered slowly`;\n\nconst chain = buildMarkovChain(source, 2);\nconst generated = generateWithProvenance(chain, 2, 60);\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 800, 400);\n\nlet x = 20;\nlet y = 40;\nconst lineHeight = 28;\n\nctx.font = '14px monospace';\n\nfor (const item of generated) {\n const metrics = ctx.measureText(item.word + ' ');\n\n if (x + metrics.width > 780) {\n x = 20;\n y += lineHeight;\n }\n\n // color from source position: different hues for different \"origins\"\n const hue = item.source * 280 + 120;\n ctx.fillStyle = `hsl(${hue}, 50%, 50%)`;\n ctx.fillText(item.word, x, y);\n\n x += metrics.width;\n}\n```\n\nEach word is colored by where it came from in the probability table. Words that had many possible successors (high entropy) get one color. Words that came from a unique, deterministic transition get another. The color pattern reveals the structure of the Markov chain itself -- predictable passages are uniform in color, unpredictable junctions create color shifts. You're visualizing not just the text but the *generation process*.\n\n## Creative exercise: text portrait of two texts\n\nTime to put it together. Take two short texts with different styles. Compute word frequency, average sentence length, and sentiment for each. Create a side-by-side visual portrait: a colored bar for each sentence (height from sentence length, color from sentiment), with the most frequent content words arranged around it.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 500;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// two contrasting texts\nconst textA = `The sun rose over the mountain and light poured into the valley.\nBirds sang in the trees and the river sparkled.\nIt was a beautiful morning and everything felt alive.\nThe children ran through the meadow laughing.\nFlowers bloomed everywhere in brilliant colors.\nA gentle breeze carried the scent of pine.\nLife was good and the world was at peace.`;\n\nconst textB = `The factory stood silent in the grey rain.\nRust crept along the walls and the windows were broken.\nNobody came here anymore.\nThe machines had stopped years ago and dust covered everything.\nWind whistled through the cracks.\nA dog wandered through the empty parking lot alone.\nThe only sound was water dripping from the rusted pipes.`;\n\nfunction analyzeText(text) {\n const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);\n return sentences.map(s => {\n const words = s.trim().split(/\\s+/);\n let score = 0;\n for (const w of words) {\n const lower = w.toLowerCase().replace(/[^a-z]/g, '');\n if (sentiment[lower]) score += sentiment[lower];\n }\n return { length: words.length, sentiment: score, text: s.trim() };\n });\n}\n\nconst analysisA = analyzeText(textA);\nconst analysisB = analyzeText(textB);\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 500);\n\nfunction drawTextPortrait(analysis, startX, label) {\n ctx.fillStyle = 'rgba(180, 180, 200, 0.5)';\n ctx.font = '12px monospace';\n ctx.textAlign = 'center';\n ctx.fillText(label, startX + 180, 30);\n\n const maxLen = 20;\n const barWidth = 360 / analysis.length - 3;\n\n for (let i = 0; i < analysis.length; i++) {\n const s = analysis[i];\n const x = startX + i * (barWidth + 3);\n const barH = (Math.min(s.length, maxLen) / maxLen) * 300;\n const y = 420 - barH;\n\n // sentiment -> color: positive = warm amber, negative = cool blue\n const sentNorm = (s.sentiment + 6) / 12;\n const hue = sentNorm * 40 + (1 - sentNorm) * 220;\n const lightness = 25 + Math.abs(s.sentiment) * 5;\n\n ctx.fillStyle = `hsl(${hue}, 55%, ${lightness}%)`;\n ctx.fillRect(x, y, barWidth, barH);\n\n // sentence length number\n ctx.fillStyle = 'rgba(160, 170, 190, 0.4)';\n ctx.font = '9px monospace';\n ctx.textAlign = 'center';\n ctx.fillText(s.length, x + barWidth / 2, 440);\n }\n}\n\ndrawTextPortrait(analysisA, 30, 'Sunny Valley');\ndrawTextPortrait(analysisB, 480, 'Abandoned Factory');\n```\n\nThe sunny valley text has warm amber bars -- positive sentiment throughout. The abandoned factory has cool blue bars -- negative sentiment. The bar heights show sentence length variation: the valley text has moderate, even sentences; the factory text has one very short sentence (\"Nobody came here anymore.\") that creates a visual dip. The two portraits are visually distinct even if you can't read the words. The color temperature alone tells you: one text is happy, one is not.\n\nSee where this is going? :-) Text is infinite creative material. Every book, every speech, every tweet, every log file has measurable properties that map to visual channels. Word frequency drives size. Sentiment drives color. Sentence length drives rhythm. Character distribution drives texture. And Markov chains can generate new text with the statistical DNA of the original. Data art from text isn't limited to word clouds -- it's an entire visual language for the written word.\n\nThe techniques from episodes 82-85 all apply here. The `map()` function from episode 82 converts word counts to pixel sizes. The log scaling from episode 83 tames Zipf's power law. The temporal layout from episode 84 works for plotting sentiment over a story. The network visualization from episode 85 could show word co-occurrence graphs. Text analysis plugs directly into the data-to-visuals pipeline we've been building.\n\n## 't Komt erop neer...\n\n- Text is unstructured data. Before you can visualize it, you have to measure it -- word frequency, sentence length, character distribution, sentiment scores. These measurements turn prose into numbers your canvas can work with\n- Word frequency analysis counts each word's occurrences and sorts by count. Stop words (\"the\", \"and\", \"is\") dominate every English text -- filter them to reveal the content words that characterize what the text is about\n- Zipf's law: in any natural language, word frequency times rank is roughly constant. This power law creates extreme distributions (a few very common words, a long tail of rare ones) that require log scaling for visual display\n- Sentence length variation reveals writing rhythm. Short sentences mean tension. Long sentences mean exposition. Plotting sentence lengths as a bar chart turns prose rhythm into visible pattern -- you can see Hemingway vs Faulkner without reading a word\n- Sentiment analysis scores text as positive or negative using dictionary lookup (AFINN lexicon: -5 to +5 per word). Crude but effective for creative coding. Plot sentiment per sentence across a story to see the emotional arc -- the shape of the narrative's mood\n- Character-to-color mapping treats each letter as a pixel. Vowels get warm tones, consonants get cool tones, spaces stay dark. The resulting grid shows the phonetic texture of the language itself -- different languages produce different visual patterns from their character frequency distributions\n- Flesch-Kincaid readability scores grade level from average sentence length and syllable count. Visualizing reading level across a text shows where the author simplifies and where they get technical\n- Word clouds work when you use spiral placement with overlap detection. Place high-frequency words first at the center, let smaller words fill gaps outward. The overlap test is what separates readable word clouds from visual garbage\n- Text comparison works through side-by-side bar charts of word frequency. Two texts in different domains (technical vs poetic) occupy completely different vocabulary spaces -- the contrast is immediately visible in the bar lengths and word labels\n- Markov chains build probability models from source text (what words follow what other words) and generate new text with the same statistical properties. Order 2 hits the creative sweet spot: locally coherent phrases, globally nonsensical. The generated text carries the statistical DNA of the source\n- Visualizing Markov generation with provenance tracking (coloring each word by its origin in the probability table) reveals the structure of the generation process itself -- predictable passages vs high-entropy junctions\n\nSallukes! Thanks for reading.\n\nX\n\n@femdev\n",
"title": "Learn Creative Coding (#86) - Text as Data: Analyzing and Visualizing Language",
"author": "femdev",
"permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
"json_metadata": "{\"tags\": [\"stem\", \"stemsocial\", \"steemstem\", \"programming\", \"creativecoding\"], \"app\": \"hiveblog/0.1\", \"format\": \"markdown\", \"image\": [\"https://images.hive.blog/DQmcX6yi9Uoqxh5UyHPgVosRfwKDaeR2JFKby1JqADcAjbD/cc-banner-amber.png\"]}",
"parent_author": "",
"parent_permlink": "hive-196387"
}
],
"block": 107035674,
"trx_id": "ca077bf257ec4dfc4c8a75651960b3728c40e551",
"op_in_trx": 0,
"timestamp": "2026-06-06T12:53:27",
"virtual_op": false,
"trx_in_block": 14
}femdevupdated payout for learn-creative-coding-79-data-as-creative-material2026/06/06 12:01:57
femdevupdated payout for learn-creative-coding-79-data-as-creative-material
2026/06/06 12:01:57
| author | femdev |
| permlink | learn-creative-coding-79-data-as-creative-material |
| Transaction Info | Block #107034647/Virtual Operation 4294967295:14 |
View Raw JSON Data
{
"op": [
"comment_payout_update",
{
"author": "femdev",
"permlink": "learn-creative-coding-79-data-as-creative-material"
}
],
"block": 107034647,
"trx_id": "0000000000000000000000000000000000000000",
"op_in_trx": 14,
"timestamp": "2026-06-06T12:01:57",
"virtual_op": true,
"trx_in_block": 4294967295
}femdevreceived 0.144 HBD reward share for learn-creative-coding-79-data-as-creative-material2026/06/06 12:01:57
femdevreceived 0.144 HBD reward share for learn-creative-coding-79-data-as-creative-material
2026/06/06 12:01:57
| author | femdev |
| payout | 0.144 HBD |
| permlink | learn-creative-coding-79-data-as-creative-material |
| author rewards | 1274 |
| total payout value | 0.072 HBD |
| curator payout value | 0.072 HBD |
| beneficiary payout value | 0.000 HBD |
| Transaction Info | Block #107034647/Virtual Operation 4294967295:13 |
View Raw JSON Data
{
"op": [
"comment_reward",
{
"author": "femdev",
"payout": "0.144 HBD",
"permlink": "learn-creative-coding-79-data-as-creative-material",
"author_rewards": 1274,
"total_payout_value": "0.072 HBD",
"curator_payout_value": "0.072 HBD",
"beneficiary_payout_value": "0.000 HBD"
}
],
"block": 107034647,
"trx_id": "0000000000000000000000000000000000000000",
"op_in_trx": 13,
"timestamp": "2026-06-06T12:01:57",
"virtual_op": true,
"trx_in_block": 4294967295
}femdevreceived 0.637 HIVE, 0.637 HP author reward for @femdev / learn-creative-coding-79-data-as-creative-material2026/06/06 12:01:57
femdevreceived 0.637 HIVE, 0.637 HP author reward for @femdev / learn-creative-coding-79-data-as-creative-material
2026/06/06 12:01:57
| author | femdev |
| permlink | learn-creative-coding-79-data-as-creative-material |
| hbd payout | 0.000 HBD |
| hive payout | 0.637 HIVE |
| vesting payout | 1034.498443 VESTS |
| payout must be claimed | true |
| curators vesting payout | 2060.876804 VESTS |
| Transaction Info | Block #107034647/Virtual Operation 4294967295:12 |
View Raw JSON Data
{
"op": [
"author_reward",
{
"author": "femdev",
"permlink": "learn-creative-coding-79-data-as-creative-material",
"hbd_payout": "0.000 HBD",
"hive_payout": "0.637 HIVE",
"vesting_payout": "1034.498443 VESTS",
"payout_must_be_claimed": true,
"curators_vesting_payout": "2060.876804 VESTS"
}
],
"block": 107034647,
"trx_id": "0000000000000000000000000000000000000000",
"op_in_trx": 12,
"timestamp": "2026-06-06T12:01:57",
"virtual_op": true,
"trx_in_block": 4294967295
}femdevreceived 0.011 HP curation reward for @femdev / learn-creative-coding-79-data-as-creative-material2026/06/06 12:01:57
femdevreceived 0.011 HP curation reward for @femdev / learn-creative-coding-79-data-as-creative-material
2026/06/06 12:01:57
| author | femdev |
| reward | 17.864180 VESTS |
| curator | femdev |
| permlink | learn-creative-coding-79-data-as-creative-material |
| payout must be claimed | true |
| Transaction Info | Block #107034647/Virtual Operation 4294967295:6 |
View Raw JSON Data
{
"op": [
"curation_reward",
{
"author": "femdev",
"reward": "17.864180 VESTS",
"curator": "femdev",
"permlink": "learn-creative-coding-79-data-as-creative-material",
"payout_must_be_claimed": true
}
],
"block": 107034647,
"trx_id": "0000000000000000000000000000000000000000",
"op_in_trx": 6,
"timestamp": "2026-06-06T12:01:57",
"virtual_op": true,
"trx_in_block": 4294967295
}stem-shturmeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 12:26:39
stem-shturmeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 12:26:39
| voter | stem-shturm |
| author | femdev |
| weight | 133577198 |
| rshares | 133577198 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.118 HBD |
| total vote weight | 1533045438341 |
| Transaction Info | Block #107006405/Trx 8eb35a9c5cbaa0cb8df89899f524ff2b619fd1e8 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "stem-shturm",
"author": "femdev",
"weight": 133577198,
"rshares": 133577198,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.118 HBD",
"total_vote_weight": 1533045438341
}
],
"block": 107006405,
"trx_id": "8eb35a9c5cbaa0cb8df89899f524ff2b619fd1e8",
"op_in_trx": 1,
"timestamp": "2026-06-05T12:26:39",
"virtual_op": true,
"trx_in_block": 24
}stem-shturmupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 12:26:39
stem-shturmupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 12:26:39
| voter | stem-shturm |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107006405/Trx 8eb35a9c5cbaa0cb8df89899f524ff2b619fd1e8 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "stem-shturm",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107006405,
"trx_id": "8eb35a9c5cbaa0cb8df89899f524ff2b619fd1e8",
"op_in_trx": 0,
"timestamp": "2026-06-05T12:26:39",
"virtual_op": false,
"trx_in_block": 24
}jeronimorubioeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 12:03:36
jeronimorubioeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 12:03:36
| voter | jeronimorubio |
| author | femdev |
| weight | 11162786581 |
| rshares | 11162786581 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.118 HBD |
| total vote weight | 1532911861143 |
| Transaction Info | Block #107005945/Trx 0e1f732413e809f195f292354dcbf7dc07fd578e |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "jeronimorubio",
"author": "femdev",
"weight": 11162786581,
"rshares": 11162786581,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.118 HBD",
"total_vote_weight": 1532911861143
}
],
"block": 107005945,
"trx_id": "0e1f732413e809f195f292354dcbf7dc07fd578e",
"op_in_trx": 1,
"timestamp": "2026-06-05T12:03:36",
"virtual_op": true,
"trx_in_block": 4
}jeronimorubioupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 12:03:36
jeronimorubioupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 12:03:36
| voter | jeronimorubio |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107005945/Trx 0e1f732413e809f195f292354dcbf7dc07fd578e |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "jeronimorubio",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107005945,
"trx_id": "0e1f732413e809f195f292354dcbf7dc07fd578e",
"op_in_trx": 0,
"timestamp": "2026-06-05T12:03:36",
"virtual_op": false,
"trx_in_block": 4
}coinmarketcaleffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:52:45
coinmarketcaleffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:52:45
| voter | coinmarketcal |
| author | femdev |
| weight | 5295441922 |
| rshares | 5295441922 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.117 HBD |
| total vote weight | 1521749074562 |
| Transaction Info | Block #107005729/Trx 001d6f0405bedbc525c12340dc299446f165e7ee |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "coinmarketcal",
"author": "femdev",
"weight": 5295441922,
"rshares": 5295441922,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.117 HBD",
"total_vote_weight": 1521749074562
}
],
"block": 107005729,
"trx_id": "001d6f0405bedbc525c12340dc299446f165e7ee",
"op_in_trx": 1,
"timestamp": "2026-06-05T11:52:45",
"virtual_op": true,
"trx_in_block": 3
}coinmarketcalupvoted (22.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:52:45
coinmarketcalupvoted (22.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:52:45
| voter | coinmarketcal |
| author | femdev |
| weight | 2200 (22.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107005729/Trx 001d6f0405bedbc525c12340dc299446f165e7ee |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "coinmarketcal",
"author": "femdev",
"weight": 2200,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107005729,
"trx_id": "001d6f0405bedbc525c12340dc299446f165e7ee",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:52:45",
"virtual_op": false,
"trx_in_block": 3
}newsrxeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:48:36
newsrxeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:48:36
| voter | newsrx |
| author | femdev |
| weight | 2061062843 |
| rshares | 2061062843 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.117 HBD |
| total vote weight | 1516453632640 |
| Transaction Info | Block #107005646/Trx cebb40b779f73999c4203213ebaee49c7c8e221c |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "newsrx",
"author": "femdev",
"weight": 2061062843,
"rshares": 2061062843,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.117 HBD",
"total_vote_weight": 1516453632640
}
],
"block": 107005646,
"trx_id": "cebb40b779f73999c4203213ebaee49c7c8e221c",
"op_in_trx": 1,
"timestamp": "2026-06-05T11:48:36",
"virtual_op": true,
"trx_in_block": 9
}newsrxupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:48:36
newsrxupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:48:36
| voter | newsrx |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107005646/Trx cebb40b779f73999c4203213ebaee49c7c8e221c |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "newsrx",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107005646,
"trx_id": "cebb40b779f73999c4203213ebaee49c7c8e221c",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:48:36",
"virtual_op": false,
"trx_in_block": 9
}blue-witnesseffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:48:24
blue-witnesseffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:48:24
| voter | blue-witness |
| author | femdev |
| weight | 3928361729 |
| rshares | 3928361729 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.117 HBD |
| total vote weight | 1514392569797 |
| Transaction Info | Block #107005642/Trx f20d8472c0ed0dbf673115ef74a35cf3ead6168b |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "blue-witness",
"author": "femdev",
"weight": 3928361729,
"rshares": 3928361729,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.117 HBD",
"total_vote_weight": 1514392569797
}
],
"block": 107005642,
"trx_id": "f20d8472c0ed0dbf673115ef74a35cf3ead6168b",
"op_in_trx": 1,
"timestamp": "2026-06-05T11:48:24",
"virtual_op": true,
"trx_in_block": 10
}blue-witnessupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:48:24
blue-witnessupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:48:24
| voter | blue-witness |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107005642/Trx f20d8472c0ed0dbf673115ef74a35cf3ead6168b |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "blue-witness",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107005642,
"trx_id": "f20d8472c0ed0dbf673115ef74a35cf3ead6168b",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:48:24",
"virtual_op": false,
"trx_in_block": 10
}steem-uaeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:48:00
steem-uaeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:48:00
| voter | steem-ua |
| author | femdev |
| weight | 513404958799 |
| rshares | 513404958799 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.116 HBD |
| total vote weight | 1510464208068 |
| Transaction Info | Block #107005634/Trx 9be426f5eb39491664df77f4ec476943f1234dfe |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "steem-ua",
"author": "femdev",
"weight": 513404958799,
"rshares": 513404958799,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.116 HBD",
"total_vote_weight": 1510464208068
}
],
"block": 107005634,
"trx_id": "9be426f5eb39491664df77f4ec476943f1234dfe",
"op_in_trx": 1,
"timestamp": "2026-06-05T11:48:00",
"virtual_op": true,
"trx_in_block": 20
}steem-uaupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:48:00
steem-uaupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:48:00
| voter | steem-ua |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107005634/Trx 9be426f5eb39491664df77f4ec476943f1234dfe |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "steem-ua",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107005634,
"trx_id": "9be426f5eb39491664df77f4ec476943f1234dfe",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:48:00",
"virtual_op": false,
"trx_in_block": 20
}scipioeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:47:54
scipioeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:47:54
| voter | scipio |
| author | femdev |
| weight | 200348428493 |
| rshares | 200348428493 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.077 HBD |
| total vote weight | 997059249269 |
| Transaction Info | Block #107005632/Trx 0111968d36f98d3e47ed3b046d809ac3459594bc |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "scipio",
"author": "femdev",
"weight": 200348428493,
"rshares": 200348428493,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.077 HBD",
"total_vote_weight": 997059249269
}
],
"block": 107005632,
"trx_id": "0111968d36f98d3e47ed3b046d809ac3459594bc",
"op_in_trx": 1,
"timestamp": "2026-06-05T11:47:54",
"virtual_op": true,
"trx_in_block": 30
}scipioupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:47:54
scipioupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:47:54
| voter | scipio |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107005632/Trx 0111968d36f98d3e47ed3b046d809ac3459594bc |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "scipio",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107005632,
"trx_id": "0111968d36f98d3e47ed3b046d809ac3459594bc",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:47:54",
"virtual_op": false,
"trx_in_block": 30
}blueroboeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:47:51
blueroboeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:47:51
| voter | bluerobo |
| author | femdev |
| weight | 769779157987 |
| rshares | 769779157987 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.061 HBD |
| total vote weight | 796710820776 |
| Transaction Info | Block #107005631/Trx 6614562ad782832e6bfdb1768cd7c591006a16dd |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "bluerobo",
"author": "femdev",
"weight": 769779157987,
"rshares": 769779157987,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.061 HBD",
"total_vote_weight": 796710820776
}
],
"block": 107005631,
"trx_id": "6614562ad782832e6bfdb1768cd7c591006a16dd",
"op_in_trx": 1,
"timestamp": "2026-06-05T11:47:51",
"virtual_op": true,
"trx_in_block": 17
}blueroboupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:47:51
blueroboupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:47:51
| voter | bluerobo |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107005631/Trx 6614562ad782832e6bfdb1768cd7c591006a16dd |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "bluerobo",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107005631,
"trx_id": "6614562ad782832e6bfdb1768cd7c591006a16dd",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:47:51",
"virtual_op": false,
"trx_in_block": 17
}femdeveffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:47:51
femdeveffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:47:51
| voter | femdev |
| author | femdev |
| weight | 17398892082 |
| rshares | 17398892082 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.002 HBD |
| total vote weight | 26931662789 |
| Transaction Info | Block #107005631/Trx 279526141c597d328f62853481241e56e549d46e |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "femdev",
"author": "femdev",
"weight": 17398892082,
"rshares": 17398892082,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.002 HBD",
"total_vote_weight": 26931662789
}
],
"block": 107005631,
"trx_id": "279526141c597d328f62853481241e56e549d46e",
"op_in_trx": 1,
"timestamp": "2026-06-05T11:47:51",
"virtual_op": true,
"trx_in_block": 11
}femdevupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:47:51
femdevupvoted (100.00%) @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:47:51
| voter | femdev |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107005631/Trx 279526141c597d328f62853481241e56e549d46e |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "femdev",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107005631,
"trx_id": "279526141c597d328f62853481241e56e549d46e",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:47:51",
"virtual_op": false,
"trx_in_block": 11
}glimpsytips.dexeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:47:48
glimpsytips.dexeffective vote applied for @femdev / learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:47:48
| voter | glimpsytips.dex |
| author | femdev |
| weight | 9532770707 |
| rshares | 9532770707 |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| pending payout | 0.000 HBD |
| total vote weight | 9532770707 |
| Transaction Info | Block #107005630/Trx 3b42f814b909d2492c94443bda2e28ae0ed68de8 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "glimpsytips.dex",
"author": "femdev",
"weight": 9532770707,
"rshares": 9532770707,
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"pending_payout": "0.000 HBD",
"total_vote_weight": 9532770707
}
],
"block": 107005630,
"trx_id": "3b42f814b909d2492c94443bda2e28ae0ed68de8",
"op_in_trx": 1,
"timestamp": "2026-06-05T11:47:48",
"virtual_op": true,
"trx_in_block": 32
}2026/06/05 11:47:48
2026/06/05 11:47:48
| voter | glimpsytips.dex |
| author | femdev |
| weight | 400 (4.00%) |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| Transaction Info | Block #107005630/Trx 3b42f814b909d2492c94443bda2e28ae0ed68de8 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "glimpsytips.dex",
"author": "femdev",
"weight": 400,
"permlink": "learn-creative-coding-85-network-and-graph-visualization"
}
],
"block": 107005630,
"trx_id": "3b42f814b909d2492c94443bda2e28ae0ed68de8",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:47:48",
"virtual_op": false,
"trx_in_block": 32
}2026/06/05 11:47:42
2026/06/05 11:47:42
| id | reblog |
| json | ["reblog", {"account": "femdev", "author": "femdev", "permlink": "learn-creative-coding-85-network-and-graph-visualization"}] |
| required auths | [] |
| required posting auths | ["femdev"] |
| Transaction Info | Block #107005628/Trx c5493268e24e164b10db7e26db0edc511fe608cc |
View Raw JSON Data
{
"op": [
"custom_json",
{
"id": "reblog",
"json": "[\"reblog\", {\"account\": \"femdev\", \"author\": \"femdev\", \"permlink\": \"learn-creative-coding-85-network-and-graph-visualization\"}]",
"required_auths": [],
"required_posting_auths": [
"femdev"
]
}
],
"block": 107005628,
"trx_id": "c5493268e24e164b10db7e26db0edc511fe608cc",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:47:42",
"virtual_op": false,
"trx_in_block": 11
}femdevpublished a new post: learn-creative-coding-85-network-and-graph-visualization2026/06/05 11:47:42
femdevpublished a new post: learn-creative-coding-85-network-and-graph-visualization
2026/06/05 11:47:42
| body | # Learn Creative Coding (#85) - Network and Graph Visualization  Last episode we visualized time -- timelines, spirals, calendar heatmaps, clock diagrams, streamgraphs, animated playback, and those dense hourly-by-daily fingerprint grids that cram 8,760 data points into a single image. Time is the most common data dimension and the way you lay it out on canvas determines which patterns appear. Straight lines ask "what happened?" Spirals ask "what repeats?" The representation IS the question. But not all data is about when or where. Some data is about *who connects to whom*. Social networks, hyperlink graphs, citation networks, character co-occurrence in novels, function call trees in codebases, biological pathways, trade routes between countries. The underlying structure isn't a sequence or a grid -- it's a *graph*. Nodes and edges. Entities and relationships. And the visual language for rendering them is completely different from anything we've built so far. This episode is about network visualization. We'll build force-directed layouts from scratch (springs and charges, simulated until equilibrium), encode node importance through size and color, detect visual communities, try alternative layouts like adjacency matrices and arc diagrams, and add the interactivity that makes dense networks actually explorable. We used physics simulation back in episode 18 for springs and flocking -- now those same forces arrange data instead of particles. ## What's a graph, visually? A graph has two things: nodes (the entities) and edges (the connections between them). A social network: nodes are people, edges are friendships. A citation network: nodes are papers, edges are references. A hyperlink graph: nodes are web pages, edges are links. The internet itself is a graph. Your brain is a graph. Most complex systems are. Visually, the classic representation is a node-link diagram: circles for nodes, lines between them for edges. Simple in concept. Nightmarishly complex in practice when you have more then about 20 nodes, because the layout problem -- where do you *put* each node so the picture is readable? -- is genuinely hard. There's no single right answer. The same graph can look like a mess or a revelation depending on where you position the nodes. ```javascript const canvas = document.createElement('canvas'); canvas.width = 600; canvas.height = 600; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // a small graph: 8 nodes, some edges const nodes = [ { id: 0, x: 150, y: 150 }, { id: 1, x: 300, y: 100 }, { id: 2, x: 450, y: 150 }, { id: 3, x: 100, y: 350 }, { id: 4, x: 300, y: 300 }, { id: 5, x: 500, y: 350 }, { id: 6, x: 200, y: 480 }, { id: 7, x: 400, y: 480 } ]; const edges = [ [0, 1], [1, 2], [0, 4], [1, 4], [2, 5], [3, 4], [3, 6], [4, 5], [4, 7], [5, 7], [6, 7] ]; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 600, 600); // draw edges for (const [a, b] of edges) { ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.strokeStyle = 'rgba(100, 140, 200, 0.3)'; ctx.lineWidth = 1.5; ctx.stroke(); } // draw nodes for (const node of nodes) { ctx.beginPath(); ctx.arc(node.x, node.y, 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.7)'; ctx.fill(); ctx.strokeStyle = 'rgba(160, 200, 255, 0.4)'; ctx.lineWidth = 1; ctx.stroke(); } ``` Eight nodes, eleven edges. I placed them by hand at positions that look reasonable. But that's the problem -- I *chose* those positions. They don't come from the data. If you add a 9th node, where does it go? If you have 200 nodes? Manual placement doesn't scale. We need an algorithm that figures out good positions automatically. ## Force-directed layout: physics as design This is the big one. The idea: treat each edge as a spring (it pulls connected nodes together) and treat each pair of nodes as repelling charges (they push apart, like magnets with the same polarity). Then simulate the physics. Connected nodes get pulled close. Unconnected nodes get pushed away. After enough iterations, the system reaches equilibrium -- a layout where the forces balance out. Clusters of densely connected nodes end up near each other, and loosely connected parts drift apart. It's the same spring physics we built in episode 18, but applied to layout instead of animation. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 700; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // generate a random graph const numNodes = 30; const nodes = []; for (let i = 0; i < numNodes; i++) { nodes.push({ x: 200 + Math.random() * 300, y: 200 + Math.random() * 300, vx: 0, vy: 0 }); } // random edges -- each node connects to 2-4 others const edges = []; for (let i = 0; i < numNodes; i++) { const numEdges = 2 + Math.floor(Math.random() * 3); for (let e = 0; e < numEdges; e++) { const j = Math.floor(Math.random() * numNodes); if (j !== i) { edges.push([i, j]); } } } function simulate() { const repulsion = 5000; const springStrength = 0.01; const springLength = 80; const damping = 0.85; // repulsion between all node pairs for (let i = 0; i < numNodes; i++) { for (let j = i + 1; j < numNodes; j++) { const dx = nodes[j].x - nodes[i].x; const dy = nodes[j].y - nodes[i].y; const dist = Math.sqrt(dx * dx + dy * dy) + 0.1; const force = repulsion / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; nodes[i].vx -= fx; nodes[i].vy -= fy; nodes[j].vx += fx; nodes[j].vy += fy; } } // spring attraction along edges for (const [a, b] of edges) { const dx = nodes[b].x - nodes[a].x; const dy = nodes[b].y - nodes[a].y; const dist = Math.sqrt(dx * dx + dy * dy) + 0.1; const displacement = dist - springLength; const fx = (dx / dist) * displacement * springStrength; const fy = (dy / dist) * displacement * springStrength; nodes[a].vx += fx; nodes[a].vy += fy; nodes[b].vx -= fx; nodes[b].vy -= fy; } // centering force (gentle pull toward canvas center) for (const node of nodes) { node.vx += (350 - node.x) * 0.001; node.vy += (350 - node.y) * 0.001; } // update positions with damping for (const node of nodes) { node.vx *= damping; node.vy *= damping; node.x += node.vx; node.y += node.vy; } } function draw() { ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 700); // edges for (const [a, b] of edges) { ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)'; ctx.lineWidth = 1; ctx.stroke(); } // nodes for (const node of nodes) { ctx.beginPath(); ctx.arc(node.x, node.y, 5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.7)'; ctx.fill(); } simulate(); requestAnimationFrame(draw); } draw(); ``` Thirty nodes, random edges. They start in a clump and then -- over a few seconds -- they sort themselves out. Connected groups drift together. Bridges between groups stretch into visible corridors. Isolated nodes float to the edges. The layout *emerges* from the physics. You didn't design it. The forces did. The three forces at work: 1. **Repulsion** (all pairs): Coulomb's law in reverse. Every node pushes every other node away. The force drops off with the square of the distance -- close nodes push hard, far nodes barely affect each other. This prevents everything from collapsing into a single point. 2. **Spring attraction** (edges only): Hooke's law. Connected nodes are pulled toward a target distance (`springLength`). If they're farther apart, the spring pulls them together. If they're closer, it pushes them apart (yes, springs do that too). This keeps connected nodes near each other. 3. **Centering** (all nodes): A weak pull toward the center of the canvas. Without this, the whole graph drifts off-screen because repulsion pushes everything outward with no counterweight. The `damping` factor (0.85) acts like friction. Each frame, velocities are reduced by 15%. Without damping, the system oscillates forever -- nodes bounce back and forth and never settle. With damping, the kinetic energy drains away and the system converges to a stable layout. ## Visual encoding: making nodes meaningful A graph where every node looks the same is a graph that's hiding information. In most real networks, nodes have properties: importance, category, size of following, number of connections. The visual encoding of those properties turns a structural diagram into a data visualization. The most important node property is **degree** -- how many edges connect to it. High-degree nodes are hubs. They hold the network together. Low-degree nodes are peripheral. You can compute degree just by counting edges per node. ```javascript // compute degree for each node const degree = new Array(numNodes).fill(0); for (const [a, b] of edges) { degree[a]++; degree[b]++; } const maxDeg = Math.max(...degree); // draw nodes with size and color from degree for (let i = 0; i < numNodes; i++) { const node = nodes[i]; const d = degree[i]; // area-proportional sizing (ep082 lesson) const area = (d / maxDeg) * 800 + 50; const r = Math.sqrt(area / Math.PI); // color: low degree = cool blue, high degree = warm amber const hue = 220 - (d / maxDeg) * 180; const lightness = 30 + (d / maxDeg) * 25; ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 55%, ${lightness}%, 0.7)`; ctx.fill(); } ``` Now the hub nodes are large and warm-colored. Peripheral nodes are small and blue. You can immediately see which nodes hold the network together without counting edges manually. The area-proportional sizing we learned in episode 82 matters here too -- mapping degree to radius instead of area would exaggerate the visual importance of hubs. `Math.sqrt(area / Math.PI)` keeps it honest. Edge thickness can carry meaning too. If your edges have weights (frequency of communication, number of shared connections, traffic volume), thicker edges mean stronger relationships: ```javascript // draw edges with thickness from weight for (const edge of weightedEdges) { ctx.beginPath(); ctx.moveTo(nodes[edge.source].x, nodes[edge.source].y); ctx.lineTo(nodes[edge.target].x, nodes[edge.target].y); const thickness = 0.5 + (edge.weight / maxWeight) * 4; ctx.strokeStyle = `rgba(80, 120, 180, ${0.1 + (edge.weight / maxWeight) * 0.4})`; ctx.lineWidth = thickness; ctx.stroke(); } ``` Thick bright edges are strong connections. Thin faint edges are weak ties. The network's backbone becomes visible -- the thick edges form the structural core, and the thin ones are the periphery. ## Community detection by color Real networks have communities -- groups of nodes that are more connected to each other than to the rest of the network. Social cliques, academic departments, music genre clusters. Visually, communities should have distinct colors so you can see the group structure at a glance. A simple (not academically rigorous but visually effective) approach: assign each node to the community of its most-connected neighbor. Repeat a few times until it converges. It's a form of label propagation. ```javascript // simple community detection: label propagation const community = []; for (let i = 0; i < numNodes; i++) { community[i] = i; // each node starts as its own community } // build adjacency list const neighbors = Array.from({ length: numNodes }, () => []); for (const [a, b] of edges) { neighbors[a].push(b); neighbors[b].push(a); } // iterate: each node adopts the most common label among neighbors for (let iter = 0; iter < 10; iter++) { for (let i = 0; i < numNodes; i++) { if (neighbors[i].length === 0) continue; const counts = {}; for (const n of neighbors[i]) { const label = community[n]; counts[label] = (counts[label] || 0) + 1; } // find the most frequent label let bestLabel = community[i]; let bestCount = 0; for (const [label, count] of Object.entries(counts)) { if (count > bestCount) { bestCount = count; bestLabel = parseInt(label); } } community[i] = bestLabel; } } // map community labels to colors const uniqueCommunities = [...new Set(community)]; const communityColors = {}; for (let i = 0; i < uniqueCommunities.length; i++) { communityColors[uniqueCommunities[i]] = (i * 137) % 360; // golden angle for good spread } // draw with community colors for (let i = 0; i < numNodes; i++) { const node = nodes[i]; const hue = communityColors[community[i]]; ctx.beginPath(); ctx.arc(node.x, node.y, 6, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`; ctx.fill(); } ``` After a few iterations, connected clusters converge on the same label. The golden angle spacing (137 degrees between successive hues) ensures that nearby community indices don't get similar colors -- same trick that sunflowers use to pack seeds efficiently. The result: clusters of same-colored nodes with differently-colored bridges between them. The community structure that's implicit in the edge list becomes explicit in the color map. ## The adjacency matrix: when node-link fails Node-link diagrams break down when graphs are dense. Above ~100 nodes with many edges, the drawing becomes a hairball -- a tangle of crossing lines where no structure is visible. The adjacency matrix is the alternative. An adjacency matrix has one row and one column per node. The cell at row i, column j is filled if there's an edge between nodes i and j. It's a 2D grid representation of the same information as the node-link diagram, but it never has crossing edges because there are no edges -- just cells. ```javascript const canvas = document.createElement('canvas'); canvas.width = 500; canvas.height = 500; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); const n = 25; // generate a clustered adjacency matrix const matrix = Array.from({ length: n }, () => new Array(n).fill(0)); // three clusters: 0-8, 9-16, 17-24 function sameCluster(i, j) { return Math.floor(i / 9) === Math.floor(j / 9); } for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { const prob = sameCluster(i, j) ? 0.6 : 0.05; if (Math.random() < prob) { matrix[i][j] = 1; matrix[j][i] = 1; } } } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 500, 500); const cellSize = 16; const offset = 40; for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { const x = offset + j * cellSize; const y = offset + i * cellSize; if (matrix[i][j]) { const cluster = Math.floor(i / 9); const hues = [200, 130, 350]; ctx.fillStyle = `hsla(${hues[cluster]}, 50%, 45%, 0.7)`; } else { ctx.fillStyle = 'rgba(25, 30, 40, 0.5)'; } ctx.fillRect(x, y, cellSize - 1, cellSize - 1); } } ``` The three clusters show up as dense colored blocks along the diagonal. Connections between clusters are sparse dots scattered off-diagonal. The block structure is unmistakable -- you can see exactly how many inter-cluster connections exist by counting the off-diagonal dots. In a node-link diagram of the same graph, those inter-cluster edges would be tangled lines crossing through the clusters and the block structure would be invisible. Adjacency matrices have a crucial property: the ordering of rows and columns matters enormously. If you shuffle the node order randomly, the blocks disappear -- the same connections become scattered cells with no visible pattern. A good adjacency matrix requires sorting nodes so that communities end up adjacent. That ordering IS the analysis -- getting it right reveals the structure, getting it wrong hides it. ## Arc diagram: the literary layout An arc diagram places all nodes along a horizontal line and draws edges as arcs above it. The arc height is proportional to the distance between connected nodes in the linear ordering. Nearby connections are short arcs. Long-range connections are tall arcs that reach across the diagram. This layout works beautifully for sequential data. Characters in a book: who interacts with whom, and how far apart are they in the narrative? Function calls in a program: which functions call which other functions, and how far apart are they in the source file? Any data with a natural linear ordering benefits from the arc layout. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 350; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // 15 "characters" in a story, with interactions const characters = [ 'Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Iris', 'Jack', 'Kate', 'Leo', 'Mia', 'Nate', 'Olive' ]; const interactions = [ [0, 1, 8], [0, 2, 3], [1, 3, 5], [2, 3, 6], [3, 5, 2], [4, 6, 7], [5, 7, 4], [6, 8, 3], [7, 9, 5], [8, 10, 2], [9, 11, 6], [10, 12, 4], [11, 13, 3], [12, 14, 5], [0, 14, 1], [3, 10, 2], [1, 7, 3], [5, 12, 2] // [source, target, weight] ]; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 350); const y = 280; const startX = 50; const spacing = 55; // draw arcs for (const [a, b, w] of interactions) { const x1 = startX + a * spacing; const x2 = startX + b * spacing; const midX = (x1 + x2) / 2; const arcHeight = Math.abs(b - a) * 18; ctx.beginPath(); ctx.moveTo(x1, y); ctx.quadraticCurveTo(midX, y - arcHeight, x2, y); ctx.strokeStyle = `rgba(140, 180, 255, ${0.15 + (w / 8) * 0.4})`; ctx.lineWidth = 0.5 + (w / 8) * 3; ctx.stroke(); } // draw nodes for (let i = 0; i < characters.length; i++) { const x = startX + i * spacing; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.8)'; ctx.fill(); ctx.save(); ctx.translate(x, y + 12); ctx.rotate(-Math.PI / 4); ctx.fillStyle = 'rgba(160, 170, 190, 0.5)'; ctx.font = '9px monospace'; ctx.textAlign = 'right'; ctx.fillText(characters[i], 0, 0); ctx.restore(); } ``` The long arc from Alice (position 0) to Olive (position 14) towers over the short arcs between adjacent characters. Edge thickness and opacity encode interaction frequency -- thicker arcs mean more interaction. You can see the narrative structure: most interactions happen between nearby characters (short arcs, dense connections), with a few long-range relationships bridging distant parts of the story. Arc diagrams are one of my favourite graph layouts for creative coding because they have strong visual rhythm. The nested arcs create a kind of topographic contour that's inherently beautiful even before you think about what the data means. :-) ## Interactive exploration: hover and highlight Networks are too complex for purely static views. With 30+ nodes, the viewer needs to be able to focus on one node at a time and see its neighborhood highlighted while everything else fades. This is where mouse interaction becomes essential. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 700; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // reuse force-directed layout from earlier // (assume nodes[] and edges[] are already computed and settled) let hoveredNode = -1; canvas.addEventListener('mousemove', function(e) { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; hoveredNode = -1; for (let i = 0; i < nodes.length; i++) { const dx = mx - nodes[i].x; const dy = my - nodes[i].y; if (dx * dx + dy * dy < 15 * 15) { hoveredNode = i; break; } } }); function drawInteractive() { ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 700); // find neighbors of hovered node const highlighted = new Set(); const highlightedEdges = new Set(); if (hoveredNode >= 0) { highlighted.add(hoveredNode); for (let e = 0; e < edges.length; e++) { const [a, b] = edges[e]; if (a === hoveredNode || b === hoveredNode) { highlighted.add(a); highlighted.add(b); highlightedEdges.add(e); } } } // draw edges for (let e = 0; e < edges.length; e++) { const [a, b] = edges[e]; ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); if (hoveredNode >= 0) { ctx.strokeStyle = highlightedEdges.has(e) ? 'rgba(120, 180, 255, 0.6)' : 'rgba(60, 70, 90, 0.1)'; ctx.lineWidth = highlightedEdges.has(e) ? 2 : 0.5; } else { ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)'; ctx.lineWidth = 1; } ctx.stroke(); } // draw nodes for (let i = 0; i < nodes.length; i++) { const isHighlighted = hoveredNode < 0 || highlighted.has(i); ctx.beginPath(); ctx.arc(nodes[i].x, nodes[i].y, isHighlighted ? 7 : 5, 0, Math.PI * 2); ctx.fillStyle = isHighlighted ? 'rgba(120, 180, 255, 0.8)' : 'rgba(50, 60, 80, 0.3)'; ctx.fill(); } requestAnimationFrame(drawInteractive); } drawInteractive(); ``` When you hover over a node, its edges light up and its direct neighbors stay visible while everything else fades to near-invisibility. The ego-network (a node plus its connections) pops out of the complexity. Move to a different node and a completely different neighborhood emerges. The same graph looks different from every node's perspective -- which is actually a deep truth about networks. Your experience of a social network depends entirely on where *you* sit in it. ## Edge bundling: taming the hairball When a graph has hundreds of edges, even a good force-directed layout produces visual clutter. Edges cross each other in every direction and the structural patterns disappear into spaghetti. Edge bundling is a technique that routes nearby edges along shared paths, like cables bundled into conduits. Parallel edges merge into streams, then diverge to their individual endpoints. The simplest approach: for each edge, instead of drawing a straight line from source to target, draw a bezier curve that bends toward the midpoint of the canvas (or toward the centroid of the graph). Edges with nearby endpoints share similar curves, creating the illusion of bundles. ```javascript // simple edge bundling: curve edges toward center const cx = 350; const cy = 350; const bundleStrength = 0.4; for (const [a, b] of edges) { const x1 = nodes[a].x; const y1 = nodes[a].y; const x2 = nodes[b].x; const y2 = nodes[b].y; // control point pulled toward center const midX = (x1 + x2) / 2; const midY = (y1 + y2) / 2; const ctrlX = midX + (cx - midX) * bundleStrength; const ctrlY = midY + (cy - midY) * bundleStrength; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.quadraticCurveTo(ctrlX, ctrlY, x2, y2); ctx.strokeStyle = 'rgba(80, 140, 220, 0.08)'; ctx.lineWidth = 1; ctx.stroke(); } ``` The `bundleStrength` parameter controls how aggressively edges are pulled toward center. At 0 you get straight lines (no bundling). At 1 all edges pass through the center (maximally bundled, but you lose endpoint information). 0.3-0.5 is usually a good range -- enough bundling to reveal flow patterns, but the individual edge endpoints are still distinguisable. With many edges at low opacity, the bundles emerge naturally from the overlapping curves. Dense bundles -- where many edges between two clusters merge into a thick stream -- glow brightly. Isolated edges stay faint. The major highways of the network become visible while the side streets fade into the background. ## Using d3-force for production layouts Building force simulation from scratch is educational (we just did it), but for production work you'd use d3-force. It's the gold standard force-directed layout engine, and you can import just the force module without the rest of D3. It handles all the physics, adaptive cooling, collision detection, and configurable force parameters. ```javascript // d3-force handles layout, we handle rendering with canvas // import { forceSimulation, forceLink, forceManyBody, forceCenter } from 'd3-force'; const simulation = d3.forceSimulation(nodes) .force('charge', d3.forceManyBody().strength(-200)) .force('link', d3.forceLink(edges).distance(60)) .force('center', d3.forceCenter(350, 350)) .on('tick', draw); function draw() { ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 700); // d3 updates node.x and node.y automatically for (const edge of edges) { ctx.beginPath(); ctx.moveTo(edge.source.x, edge.source.y); ctx.lineTo(edge.target.x, edge.target.y); ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)'; ctx.lineWidth = 1; ctx.stroke(); } for (const node of nodes) { ctx.beginPath(); ctx.arc(node.x, node.y, 5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.7)'; ctx.fill(); } } ``` The key difference from our DIY version: d3-force uses adaptive cooling. The simulation starts "hot" (large movements each tick) and gradually cools down (smaller and smaller movements) until it stops. Our manual version used constant damping, which works but takes longer to settle. D3's cooling schedule converges faster and produces smoother results. You don't need to use D3 for rendering -- that's the whole point. D3 calculates the positions, and you render with canvas (or p5, or WebGL, or whatever). Separation of layout from rendering. The layout engine is a tool, the visual style is yours. ## Creative exercise: character co-occurrence network Allez, time to build something real. A character co-occurrence network from a novel. Two characters are connected if they appear in the same paragraph. The more paragraphs they share, the stronger the connection. Node size from total appearances, edge weight from co-occurrence count. Force-directed layout, colored by community. We'll use a simplified dataset -- manually defining character appearances would take forever for a full novel, so we'll simulate the pattern with a fake "book" that has the right statistical structure. ```javascript const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 800; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // simulated book characters with appearance counts and co-occurrence const characters = [ { name: 'Protagonist', appearances: 180, group: 0 }, { name: 'Mentor', appearances: 95, group: 0 }, { name: 'Sidekick', appearances: 120, group: 0 }, { name: 'Villain', appearances: 85, group: 1 }, { name: 'Henchman1', appearances: 40, group: 1 }, { name: 'Henchman2', appearances: 35, group: 1 }, { name: 'Love Interest', appearances: 75, group: 0 }, { name: 'Rival', appearances: 60, group: 2 }, { name: 'Friend1', appearances: 45, group: 2 }, { name: 'Friend2', appearances: 50, group: 2 }, { name: 'Elder', appearances: 30, group: 0 }, { name: 'Spy', appearances: 55, group: 1 }, { name: 'Narrator', appearances: 70, group: 0 }, { name: 'Merchant', appearances: 25, group: 2 }, { name: 'Guard', appearances: 20, group: 1 } ]; // co-occurrence edges (source, target, shared paragraphs) const cooccurrence = [ [0, 1, 45], [0, 2, 70], [0, 3, 30], [0, 6, 40], [0, 7, 25], [0, 12, 35], [1, 2, 20], [1, 10, 15], [1, 12, 18], [2, 6, 15], [2, 7, 12], [3, 4, 30], [3, 5, 25], [3, 11, 22], [4, 5, 18], [4, 14, 12], [5, 14, 10], [6, 12, 8], [7, 8, 20], [7, 9, 18], [8, 9, 25], [8, 13, 10], [9, 13, 8], [10, 12, 6], [11, 3, 15], [11, 14, 8], [0, 11, 10], [2, 3, 8] ]; // initialize node positions const nodes = characters.map((c, i) => ({ x: 400 + (Math.random() - 0.5) * 200, y: 400 + (Math.random() - 0.5) * 200, vx: 0, vy: 0, ...c })); // run force simulation for 200 steps for (let step = 0; step < 200; step++) { const cooling = 1.0 - step / 200; // repulsion for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const dx = nodes[j].x - nodes[i].x; const dy = nodes[j].y - nodes[i].y; const dist = Math.sqrt(dx * dx + dy * dy) + 1; const force = 4000 / (dist * dist); const fx = (dx / dist) * force * cooling; const fy = (dy / dist) * force * cooling; nodes[i].vx -= fx; nodes[i].vy -= fy; nodes[j].vx += fx; nodes[j].vy += fy; } } // spring attraction for (const [a, b, w] of cooccurrence) { const dx = nodes[b].x - nodes[a].x; const dy = nodes[b].y - nodes[a].y; const dist = Math.sqrt(dx * dx + dy * dy) + 1; const strength = 0.005 + (w / 70) * 0.01; const target = 70; const disp = dist - target; const fx = (dx / dist) * disp * strength * cooling; const fy = (dy / dist) * disp * strength * cooling; nodes[a].vx += fx; nodes[a].vy += fy; nodes[b].vx -= fx; nodes[b].vy -= fy; } // center + damping for (const node of nodes) { node.vx += (400 - node.x) * 0.002; node.vy += (400 - node.y) * 0.002; node.vx *= 0.8; node.vy *= 0.8; node.x += node.vx; node.y += node.vy; } } // draw ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 800, 800); const groupHues = [200, 350, 130]; const maxApp = Math.max(...characters.map(c => c.appearances)); const maxW = Math.max(...cooccurrence.map(e => e[2])); // edges for (const [a, b, w] of cooccurrence) { ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.strokeStyle = `rgba(100, 130, 180, ${0.05 + (w / maxW) * 0.3})`; ctx.lineWidth = 0.5 + (w / maxW) * 3; ctx.stroke(); } // nodes for (let i = 0; i < nodes.length; i++) { const n = nodes[i]; const area = (n.appearances / maxApp) * 1200 + 80; const r = Math.sqrt(area / Math.PI); const hue = groupHues[n.group]; // glow ctx.beginPath(); ctx.arc(n.x, n.y, r + 4, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 50%, 50%, 0.12)`; ctx.fill(); // core ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`; ctx.fill(); // label ctx.fillStyle = 'rgba(180, 190, 210, 0.5)'; ctx.font = '9px monospace'; ctx.textAlign = 'center'; ctx.fillText(n.name, n.x, n.y + r + 12); } ``` The Protagonist dominates the center -- highest degree, most co-occurrences, pulled toward everyone. The Villain's group (red/pink) clusters separately but connects to the Protagonist through direct encounters and through the Spy who bridges both worlds. The Friend group (green) orbits the Rival, connected to the hero side but with their own internal density. The Elder and Narrator drift near the hero cluster but at the periphery -- they appear less frequently. This is what network visualization does that no other technique can: it reveals *relational structure*. Not "how much" or "when" or "where" -- but "who connects to whom, and how tightly." The social architecture of a novel, visible in a single image. If you fed a real book through a paragraph co-occurrence counter (Project Gutenberg has plenty of free texts), the network would reveal the book's social structure in a way that reading the text cover-to-cover doesn't make explicit. ## Radial layout: hierarchy as rings Not all graphs are flat peer-to-peer networks. Some have hierarchy -- a root node with children, grandchildren, great-grandchildren. Organizational charts, file system trees, taxonomy classifications. A radial tree layout places the root at the center and arranges each level of the hierarchy as a concentric ring. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 700; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // build a tree: root has 4 children, each has 2-4 grandchildren const tree = { id: 0, children: [] }; let nextId = 1; for (let i = 0; i < 4; i++) { const child = { id: nextId++, children: [] }; const numGrand = 2 + Math.floor(Math.random() * 3); for (let j = 0; j < numGrand; j++) { const grand = { id: nextId++, children: [] }; // some grandchildren have leaves if (Math.random() < 0.5) { const numLeaves = 1 + Math.floor(Math.random() * 3); for (let k = 0; k < numLeaves; k++) { grand.children.push({ id: nextId++, children: [] }); } } child.children.push(grand); } tree.children.push(child); } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 700); const cx = 350; const cy = 350; function drawRadialTree(node, startAngle, endAngle, depth) { const radius = depth * 80; const angle = (startAngle + endAngle) / 2; const x = cx + Math.cos(angle) * radius; const y = cy + Math.sin(angle) * radius; // draw edge to parent (except root) if (depth > 0) { const parentAngle = angle; const parentR = (depth - 1) * 80; const px = cx + Math.cos(parentAngle) * parentR; const py = cy + Math.sin(parentAngle) * parentR; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(x, y); ctx.strokeStyle = `rgba(80, 120, 180, ${0.5 - depth * 0.1})`; ctx.lineWidth = 3 - depth * 0.5; ctx.stroke(); } // draw node const r = depth === 0 ? 8 : 4; const hue = 200 + depth * 40; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 50%, 55%, 0.7)`; ctx.fill(); // recurse for children if (node.children.length > 0) { const sliceSize = (endAngle - startAngle) / node.children.length; for (let i = 0; i < node.children.length; i++) { const childStart = startAngle + i * sliceSize; const childEnd = childStart + sliceSize; drawRadialTree(node.children[i], childStart, childEnd, depth + 1); } } } drawRadialTree(tree, 0, Math.PI * 2, 0); ``` The root sits at the center. Four branches radiate outward, each claiming a quarter of the circle (roughly). Grandchildren subdivide their parent's angular range. Leaves sit at the outermost ring. The radial layout uses space efficiently -- linear tree layouts waste horizontal space because deep trees get very wide, but the radial tree wraps everything into a compact circle. The color shifts from blue (root) to warmer tones (leaves), giving you a visual depth cue. Edge thickness decreases with depth too -- the trunk is thick, the branches thin, matching the visual metaphor of an actual tree. ## What's coming We've got geography (ep083), time (ep084), and now networks (this episode) -- three fundamentally different data structures, each with its own visual language. But most real-world data isn't neatly geographic, temporal, or relational. It's messy text -- articles, tweets, logs, books. Extracting visual patterns from text requires a different set of techniques: word frequency, sentiment analysis, linguistic structure. And text happens to be one of the richest sources of creative raw material when you know how to parse it. ## 't Komt erop neer... - Networks have two elements: nodes (entities) and edges (connections). Social networks, citation graphs, hyperlinks, character co-occurrence, function call trees -- most complex systems are graphs. The visual language is node-link diagrams (circles and lines), but layout is the hard problem: where you place nodes determines whether the picture is readable or a hairball - Force-directed layout treats edges as springs and node pairs as repelling charges, then simulates physics until equilibrium. Connected nodes cluster together, unconnected nodes drift apart. Three forces: repulsion (all pairs, inverse square), spring attraction (edges only, Hooke's law), and centering (weak pull to canvas center). Damping drains kinetic energy so the system converges - Visual encoding makes nodes meaningful: size from degree (area-proportional using `Math.sqrt`), color from category or importance, edge thickness from weight. Without encoding, all nodes look the same and the graph hides its information - Community detection assigns colors to clusters of densely connected nodes. Label propagation is simple: each node adopts the most common label among its neighbors, iterated until convergence. Golden angle spacing (137 degrees between hues) prevents adjacent community indices from getting similar colors - Adjacency matrices represent edges as colored cells in a grid (row i, column j filled if there's an edge). They never have crossing edges and scale to dense graphs where node-link diagrams become hairballs. But node ordering matters critically -- random order hides the block structure, sorted order reveals it - Arc diagrams place nodes on a horizontal line with edges as arcs above. Arc height shows the distance between connected nodes in the linear ordering. Works beautifully for sequential data -- characters in a story, functions in source code -- where the linear order carries meaning - Interactive hover-and-highlight is essential for networks above ~30 nodes. Highlight one node's edges and neighbors while fading everything else. The ego-network pops out of the complexity. Same graph looks different from every node's perspective - Edge bundling reduces visual clutter by routing nearby edges along shared curves. Bend each edge's bezier control point toward a common attractor (graph centroid). Bundled edges at low opacity reveal major flow patterns while isolated edges fade - D3-force is the production standard for force layout. Import just the force module, let it compute positions, and render with canvas. Adaptive cooling converges faster than constant damping. Separation of layout engine from rendering keeps your visual style flexible - Radial tree layout places the root at center with each hierarchy level as a concentric ring. Children subdivide their parent's angular range. Compact and space-efficient compared to linear tree layouts. Color and line thickness shifting with depth reinforces the hierarchy visually Sallukes! Thanks for reading. X @femdev |
| title | Learn Creative Coding (#85) - Network and Graph Visualization |
| author | femdev |
| permlink | learn-creative-coding-85-network-and-graph-visualization |
| json metadata | {"tags": ["stem", "stemsocial", "steemstem", "programming", "creativecoding"], "app": "hiveblog/0.1", "format": "markdown", "image": ["https://images.hive.blog/DQmZCVibociQsR6XsLDTrnzNBjz47h1z3ocJGFZgerPf7xM/cc-banner-red.png"]} |
| parent author | |
| parent permlink | hive-196387 |
| Transaction Info | Block #107005628/Trx 39157976d1a0075edc574ccddecafea4ea97491f |
View Raw JSON Data
{
"op": [
"comment",
{
"body": "# Learn Creative Coding (#85) - Network and Graph Visualization\n\n\n\nLast episode we visualized time -- timelines, spirals, calendar heatmaps, clock diagrams, streamgraphs, animated playback, and those dense hourly-by-daily fingerprint grids that cram 8,760 data points into a single image. Time is the most common data dimension and the way you lay it out on canvas determines which patterns appear. Straight lines ask \"what happened?\" Spirals ask \"what repeats?\" The representation IS the question.\n\nBut not all data is about when or where. Some data is about *who connects to whom*. Social networks, hyperlink graphs, citation networks, character co-occurrence in novels, function call trees in codebases, biological pathways, trade routes between countries. The underlying structure isn't a sequence or a grid -- it's a *graph*. Nodes and edges. Entities and relationships. And the visual language for rendering them is completely different from anything we've built so far.\n\nThis episode is about network visualization. We'll build force-directed layouts from scratch (springs and charges, simulated until equilibrium), encode node importance through size and color, detect visual communities, try alternative layouts like adjacency matrices and arc diagrams, and add the interactivity that makes dense networks actually explorable. We used physics simulation back in episode 18 for springs and flocking -- now those same forces arrange data instead of particles.\n\n## What's a graph, visually?\n\nA graph has two things: nodes (the entities) and edges (the connections between them). A social network: nodes are people, edges are friendships. A citation network: nodes are papers, edges are references. A hyperlink graph: nodes are web pages, edges are links. The internet itself is a graph. Your brain is a graph. Most complex systems are.\n\nVisually, the classic representation is a node-link diagram: circles for nodes, lines between them for edges. Simple in concept. Nightmarishly complex in practice when you have more then about 20 nodes, because the layout problem -- where do you *put* each node so the picture is readable? -- is genuinely hard. There's no single right answer. The same graph can look like a mess or a revelation depending on where you position the nodes.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 600;\ncanvas.height = 600;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// a small graph: 8 nodes, some edges\nconst nodes = [\n { id: 0, x: 150, y: 150 },\n { id: 1, x: 300, y: 100 },\n { id: 2, x: 450, y: 150 },\n { id: 3, x: 100, y: 350 },\n { id: 4, x: 300, y: 300 },\n { id: 5, x: 500, y: 350 },\n { id: 6, x: 200, y: 480 },\n { id: 7, x: 400, y: 480 }\n];\n\nconst edges = [\n [0, 1], [1, 2], [0, 4], [1, 4],\n [2, 5], [3, 4], [3, 6], [4, 5],\n [4, 7], [5, 7], [6, 7]\n];\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 600, 600);\n\n// draw edges\nfor (const [a, b] of edges) {\n ctx.beginPath();\n ctx.moveTo(nodes[a].x, nodes[a].y);\n ctx.lineTo(nodes[b].x, nodes[b].y);\n ctx.strokeStyle = 'rgba(100, 140, 200, 0.3)';\n ctx.lineWidth = 1.5;\n ctx.stroke();\n}\n\n// draw nodes\nfor (const node of nodes) {\n ctx.beginPath();\n ctx.arc(node.x, node.y, 8, 0, Math.PI * 2);\n ctx.fillStyle = 'rgba(120, 180, 255, 0.7)';\n ctx.fill();\n ctx.strokeStyle = 'rgba(160, 200, 255, 0.4)';\n ctx.lineWidth = 1;\n ctx.stroke();\n}\n```\n\nEight nodes, eleven edges. I placed them by hand at positions that look reasonable. But that's the problem -- I *chose* those positions. They don't come from the data. If you add a 9th node, where does it go? If you have 200 nodes? Manual placement doesn't scale. We need an algorithm that figures out good positions automatically.\n\n## Force-directed layout: physics as design\n\nThis is the big one. The idea: treat each edge as a spring (it pulls connected nodes together) and treat each pair of nodes as repelling charges (they push apart, like magnets with the same polarity). Then simulate the physics. Connected nodes get pulled close. Unconnected nodes get pushed away. After enough iterations, the system reaches equilibrium -- a layout where the forces balance out. Clusters of densely connected nodes end up near each other, and loosely connected parts drift apart.\n\nIt's the same spring physics we built in episode 18, but applied to layout instead of animation.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 700;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// generate a random graph\nconst numNodes = 30;\nconst nodes = [];\nfor (let i = 0; i < numNodes; i++) {\n nodes.push({\n x: 200 + Math.random() * 300,\n y: 200 + Math.random() * 300,\n vx: 0,\n vy: 0\n });\n}\n\n// random edges -- each node connects to 2-4 others\nconst edges = [];\nfor (let i = 0; i < numNodes; i++) {\n const numEdges = 2 + Math.floor(Math.random() * 3);\n for (let e = 0; e < numEdges; e++) {\n const j = Math.floor(Math.random() * numNodes);\n if (j !== i) {\n edges.push([i, j]);\n }\n }\n}\n\nfunction simulate() {\n const repulsion = 5000;\n const springStrength = 0.01;\n const springLength = 80;\n const damping = 0.85;\n\n // repulsion between all node pairs\n for (let i = 0; i < numNodes; i++) {\n for (let j = i + 1; j < numNodes; j++) {\n const dx = nodes[j].x - nodes[i].x;\n const dy = nodes[j].y - nodes[i].y;\n const dist = Math.sqrt(dx * dx + dy * dy) + 0.1;\n const force = repulsion / (dist * dist);\n const fx = (dx / dist) * force;\n const fy = (dy / dist) * force;\n\n nodes[i].vx -= fx;\n nodes[i].vy -= fy;\n nodes[j].vx += fx;\n nodes[j].vy += fy;\n }\n }\n\n // spring attraction along edges\n for (const [a, b] of edges) {\n const dx = nodes[b].x - nodes[a].x;\n const dy = nodes[b].y - nodes[a].y;\n const dist = Math.sqrt(dx * dx + dy * dy) + 0.1;\n const displacement = dist - springLength;\n const fx = (dx / dist) * displacement * springStrength;\n const fy = (dy / dist) * displacement * springStrength;\n\n nodes[a].vx += fx;\n nodes[a].vy += fy;\n nodes[b].vx -= fx;\n nodes[b].vy -= fy;\n }\n\n // centering force (gentle pull toward canvas center)\n for (const node of nodes) {\n node.vx += (350 - node.x) * 0.001;\n node.vy += (350 - node.y) * 0.001;\n }\n\n // update positions with damping\n for (const node of nodes) {\n node.vx *= damping;\n node.vy *= damping;\n node.x += node.vx;\n node.y += node.vy;\n }\n}\n\nfunction draw() {\n ctx.fillStyle = '#0a0a1a';\n ctx.fillRect(0, 0, 700, 700);\n\n // edges\n for (const [a, b] of edges) {\n ctx.beginPath();\n ctx.moveTo(nodes[a].x, nodes[a].y);\n ctx.lineTo(nodes[b].x, nodes[b].y);\n ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)';\n ctx.lineWidth = 1;\n ctx.stroke();\n }\n\n // nodes\n for (const node of nodes) {\n ctx.beginPath();\n ctx.arc(node.x, node.y, 5, 0, Math.PI * 2);\n ctx.fillStyle = 'rgba(120, 180, 255, 0.7)';\n ctx.fill();\n }\n\n simulate();\n requestAnimationFrame(draw);\n}\n\ndraw();\n```\n\nThirty nodes, random edges. They start in a clump and then -- over a few seconds -- they sort themselves out. Connected groups drift together. Bridges between groups stretch into visible corridors. Isolated nodes float to the edges. The layout *emerges* from the physics. You didn't design it. The forces did.\n\nThe three forces at work:\n\n1. **Repulsion** (all pairs): Coulomb's law in reverse. Every node pushes every other node away. The force drops off with the square of the distance -- close nodes push hard, far nodes barely affect each other. This prevents everything from collapsing into a single point.\n\n2. **Spring attraction** (edges only): Hooke's law. Connected nodes are pulled toward a target distance (`springLength`). If they're farther apart, the spring pulls them together. If they're closer, it pushes them apart (yes, springs do that too). This keeps connected nodes near each other.\n\n3. **Centering** (all nodes): A weak pull toward the center of the canvas. Without this, the whole graph drifts off-screen because repulsion pushes everything outward with no counterweight.\n\nThe `damping` factor (0.85) acts like friction. Each frame, velocities are reduced by 15%. Without damping, the system oscillates forever -- nodes bounce back and forth and never settle. With damping, the kinetic energy drains away and the system converges to a stable layout.\n\n## Visual encoding: making nodes meaningful\n\nA graph where every node looks the same is a graph that's hiding information. In most real networks, nodes have properties: importance, category, size of following, number of connections. The visual encoding of those properties turns a structural diagram into a data visualization.\n\nThe most important node property is **degree** -- how many edges connect to it. High-degree nodes are hubs. They hold the network together. Low-degree nodes are peripheral. You can compute degree just by counting edges per node.\n\n```javascript\n// compute degree for each node\nconst degree = new Array(numNodes).fill(0);\nfor (const [a, b] of edges) {\n degree[a]++;\n degree[b]++;\n}\n\nconst maxDeg = Math.max(...degree);\n\n// draw nodes with size and color from degree\nfor (let i = 0; i < numNodes; i++) {\n const node = nodes[i];\n const d = degree[i];\n\n // area-proportional sizing (ep082 lesson)\n const area = (d / maxDeg) * 800 + 50;\n const r = Math.sqrt(area / Math.PI);\n\n // color: low degree = cool blue, high degree = warm amber\n const hue = 220 - (d / maxDeg) * 180;\n const lightness = 30 + (d / maxDeg) * 25;\n\n ctx.beginPath();\n ctx.arc(node.x, node.y, r, 0, Math.PI * 2);\n ctx.fillStyle = `hsla(${hue}, 55%, ${lightness}%, 0.7)`;\n ctx.fill();\n}\n```\n\nNow the hub nodes are large and warm-colored. Peripheral nodes are small and blue. You can immediately see which nodes hold the network together without counting edges manually. The area-proportional sizing we learned in episode 82 matters here too -- mapping degree to radius instead of area would exaggerate the visual importance of hubs. `Math.sqrt(area / Math.PI)` keeps it honest.\n\nEdge thickness can carry meaning too. If your edges have weights (frequency of communication, number of shared connections, traffic volume), thicker edges mean stronger relationships:\n\n```javascript\n// draw edges with thickness from weight\nfor (const edge of weightedEdges) {\n ctx.beginPath();\n ctx.moveTo(nodes[edge.source].x, nodes[edge.source].y);\n ctx.lineTo(nodes[edge.target].x, nodes[edge.target].y);\n\n const thickness = 0.5 + (edge.weight / maxWeight) * 4;\n ctx.strokeStyle = `rgba(80, 120, 180, ${0.1 + (edge.weight / maxWeight) * 0.4})`;\n ctx.lineWidth = thickness;\n ctx.stroke();\n}\n```\n\nThick bright edges are strong connections. Thin faint edges are weak ties. The network's backbone becomes visible -- the thick edges form the structural core, and the thin ones are the periphery.\n\n## Community detection by color\n\nReal networks have communities -- groups of nodes that are more connected to each other than to the rest of the network. Social cliques, academic departments, music genre clusters. Visually, communities should have distinct colors so you can see the group structure at a glance.\n\nA simple (not academically rigorous but visually effective) approach: assign each node to the community of its most-connected neighbor. Repeat a few times until it converges. It's a form of label propagation.\n\n```javascript\n// simple community detection: label propagation\nconst community = [];\nfor (let i = 0; i < numNodes; i++) {\n community[i] = i; // each node starts as its own community\n}\n\n// build adjacency list\nconst neighbors = Array.from({ length: numNodes }, () => []);\nfor (const [a, b] of edges) {\n neighbors[a].push(b);\n neighbors[b].push(a);\n}\n\n// iterate: each node adopts the most common label among neighbors\nfor (let iter = 0; iter < 10; iter++) {\n for (let i = 0; i < numNodes; i++) {\n if (neighbors[i].length === 0) continue;\n\n const counts = {};\n for (const n of neighbors[i]) {\n const label = community[n];\n counts[label] = (counts[label] || 0) + 1;\n }\n\n // find the most frequent label\n let bestLabel = community[i];\n let bestCount = 0;\n for (const [label, count] of Object.entries(counts)) {\n if (count > bestCount) {\n bestCount = count;\n bestLabel = parseInt(label);\n }\n }\n community[i] = bestLabel;\n }\n}\n\n// map community labels to colors\nconst uniqueCommunities = [...new Set(community)];\nconst communityColors = {};\nfor (let i = 0; i < uniqueCommunities.length; i++) {\n communityColors[uniqueCommunities[i]] = (i * 137) % 360; // golden angle for good spread\n}\n\n// draw with community colors\nfor (let i = 0; i < numNodes; i++) {\n const node = nodes[i];\n const hue = communityColors[community[i]];\n\n ctx.beginPath();\n ctx.arc(node.x, node.y, 6, 0, Math.PI * 2);\n ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`;\n ctx.fill();\n}\n```\n\nAfter a few iterations, connected clusters converge on the same label. The golden angle spacing (137 degrees between successive hues) ensures that nearby community indices don't get similar colors -- same trick that sunflowers use to pack seeds efficiently. The result: clusters of same-colored nodes with differently-colored bridges between them. The community structure that's implicit in the edge list becomes explicit in the color map.\n\n## The adjacency matrix: when node-link fails\n\nNode-link diagrams break down when graphs are dense. Above ~100 nodes with many edges, the drawing becomes a hairball -- a tangle of crossing lines where no structure is visible. The adjacency matrix is the alternative.\n\nAn adjacency matrix has one row and one column per node. The cell at row i, column j is filled if there's an edge between nodes i and j. It's a 2D grid representation of the same information as the node-link diagram, but it never has crossing edges because there are no edges -- just cells.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 500;\ncanvas.height = 500;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\nconst n = 25;\n// generate a clustered adjacency matrix\nconst matrix = Array.from({ length: n }, () => new Array(n).fill(0));\n\n// three clusters: 0-8, 9-16, 17-24\nfunction sameCluster(i, j) {\n return Math.floor(i / 9) === Math.floor(j / 9);\n}\n\nfor (let i = 0; i < n; i++) {\n for (let j = i + 1; j < n; j++) {\n const prob = sameCluster(i, j) ? 0.6 : 0.05;\n if (Math.random() < prob) {\n matrix[i][j] = 1;\n matrix[j][i] = 1;\n }\n }\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 500, 500);\n\nconst cellSize = 16;\nconst offset = 40;\n\nfor (let i = 0; i < n; i++) {\n for (let j = 0; j < n; j++) {\n const x = offset + j * cellSize;\n const y = offset + i * cellSize;\n\n if (matrix[i][j]) {\n const cluster = Math.floor(i / 9);\n const hues = [200, 130, 350];\n ctx.fillStyle = `hsla(${hues[cluster]}, 50%, 45%, 0.7)`;\n } else {\n ctx.fillStyle = 'rgba(25, 30, 40, 0.5)';\n }\n\n ctx.fillRect(x, y, cellSize - 1, cellSize - 1);\n }\n}\n```\n\nThe three clusters show up as dense colored blocks along the diagonal. Connections between clusters are sparse dots scattered off-diagonal. The block structure is unmistakable -- you can see exactly how many inter-cluster connections exist by counting the off-diagonal dots. In a node-link diagram of the same graph, those inter-cluster edges would be tangled lines crossing through the clusters and the block structure would be invisible.\n\nAdjacency matrices have a crucial property: the ordering of rows and columns matters enormously. If you shuffle the node order randomly, the blocks disappear -- the same connections become scattered cells with no visible pattern. A good adjacency matrix requires sorting nodes so that communities end up adjacent. That ordering IS the analysis -- getting it right reveals the structure, getting it wrong hides it.\n\n## Arc diagram: the literary layout\n\nAn arc diagram places all nodes along a horizontal line and draws edges as arcs above it. The arc height is proportional to the distance between connected nodes in the linear ordering. Nearby connections are short arcs. Long-range connections are tall arcs that reach across the diagram.\n\nThis layout works beautifully for sequential data. Characters in a book: who interacts with whom, and how far apart are they in the narrative? Function calls in a program: which functions call which other functions, and how far apart are they in the source file? Any data with a natural linear ordering benefits from the arc layout.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 350;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// 15 \"characters\" in a story, with interactions\nconst characters = [\n 'Alice', 'Bob', 'Carol', 'Dan', 'Eve',\n 'Frank', 'Grace', 'Hank', 'Iris', 'Jack',\n 'Kate', 'Leo', 'Mia', 'Nate', 'Olive'\n];\n\nconst interactions = [\n [0, 1, 8], [0, 2, 3], [1, 3, 5], [2, 3, 6],\n [3, 5, 2], [4, 6, 7], [5, 7, 4], [6, 8, 3],\n [7, 9, 5], [8, 10, 2], [9, 11, 6], [10, 12, 4],\n [11, 13, 3], [12, 14, 5], [0, 14, 1], [3, 10, 2],\n [1, 7, 3], [5, 12, 2]\n // [source, target, weight]\n];\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 350);\n\nconst y = 280;\nconst startX = 50;\nconst spacing = 55;\n\n// draw arcs\nfor (const [a, b, w] of interactions) {\n const x1 = startX + a * spacing;\n const x2 = startX + b * spacing;\n const midX = (x1 + x2) / 2;\n const arcHeight = Math.abs(b - a) * 18;\n\n ctx.beginPath();\n ctx.moveTo(x1, y);\n ctx.quadraticCurveTo(midX, y - arcHeight, x2, y);\n ctx.strokeStyle = `rgba(140, 180, 255, ${0.15 + (w / 8) * 0.4})`;\n ctx.lineWidth = 0.5 + (w / 8) * 3;\n ctx.stroke();\n}\n\n// draw nodes\nfor (let i = 0; i < characters.length; i++) {\n const x = startX + i * spacing;\n\n ctx.beginPath();\n ctx.arc(x, y, 4, 0, Math.PI * 2);\n ctx.fillStyle = 'rgba(120, 180, 255, 0.8)';\n ctx.fill();\n\n ctx.save();\n ctx.translate(x, y + 12);\n ctx.rotate(-Math.PI / 4);\n ctx.fillStyle = 'rgba(160, 170, 190, 0.5)';\n ctx.font = '9px monospace';\n ctx.textAlign = 'right';\n ctx.fillText(characters[i], 0, 0);\n ctx.restore();\n}\n```\n\nThe long arc from Alice (position 0) to Olive (position 14) towers over the short arcs between adjacent characters. Edge thickness and opacity encode interaction frequency -- thicker arcs mean more interaction. You can see the narrative structure: most interactions happen between nearby characters (short arcs, dense connections), with a few long-range relationships bridging distant parts of the story.\n\nArc diagrams are one of my favourite graph layouts for creative coding because they have strong visual rhythm. The nested arcs create a kind of topographic contour that's inherently beautiful even before you think about what the data means. :-)\n\n## Interactive exploration: hover and highlight\n\nNetworks are too complex for purely static views. With 30+ nodes, the viewer needs to be able to focus on one node at a time and see its neighborhood highlighted while everything else fades. This is where mouse interaction becomes essential.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 700;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// reuse force-directed layout from earlier\n// (assume nodes[] and edges[] are already computed and settled)\n\nlet hoveredNode = -1;\n\ncanvas.addEventListener('mousemove', function(e) {\n const rect = canvas.getBoundingClientRect();\n const mx = e.clientX - rect.left;\n const my = e.clientY - rect.top;\n\n hoveredNode = -1;\n for (let i = 0; i < nodes.length; i++) {\n const dx = mx - nodes[i].x;\n const dy = my - nodes[i].y;\n if (dx * dx + dy * dy < 15 * 15) {\n hoveredNode = i;\n break;\n }\n }\n});\n\nfunction drawInteractive() {\n ctx.fillStyle = '#0a0a1a';\n ctx.fillRect(0, 0, 700, 700);\n\n // find neighbors of hovered node\n const highlighted = new Set();\n const highlightedEdges = new Set();\n if (hoveredNode >= 0) {\n highlighted.add(hoveredNode);\n for (let e = 0; e < edges.length; e++) {\n const [a, b] = edges[e];\n if (a === hoveredNode || b === hoveredNode) {\n highlighted.add(a);\n highlighted.add(b);\n highlightedEdges.add(e);\n }\n }\n }\n\n // draw edges\n for (let e = 0; e < edges.length; e++) {\n const [a, b] = edges[e];\n ctx.beginPath();\n ctx.moveTo(nodes[a].x, nodes[a].y);\n ctx.lineTo(nodes[b].x, nodes[b].y);\n\n if (hoveredNode >= 0) {\n ctx.strokeStyle = highlightedEdges.has(e)\n ? 'rgba(120, 180, 255, 0.6)'\n : 'rgba(60, 70, 90, 0.1)';\n ctx.lineWidth = highlightedEdges.has(e) ? 2 : 0.5;\n } else {\n ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)';\n ctx.lineWidth = 1;\n }\n ctx.stroke();\n }\n\n // draw nodes\n for (let i = 0; i < nodes.length; i++) {\n const isHighlighted = hoveredNode < 0 || highlighted.has(i);\n\n ctx.beginPath();\n ctx.arc(nodes[i].x, nodes[i].y, isHighlighted ? 7 : 5, 0, Math.PI * 2);\n ctx.fillStyle = isHighlighted\n ? 'rgba(120, 180, 255, 0.8)'\n : 'rgba(50, 60, 80, 0.3)';\n ctx.fill();\n }\n\n requestAnimationFrame(drawInteractive);\n}\n\ndrawInteractive();\n```\n\nWhen you hover over a node, its edges light up and its direct neighbors stay visible while everything else fades to near-invisibility. The ego-network (a node plus its connections) pops out of the complexity. Move to a different node and a completely different neighborhood emerges. The same graph looks different from every node's perspective -- which is actually a deep truth about networks. Your experience of a social network depends entirely on where *you* sit in it.\n\n## Edge bundling: taming the hairball\n\nWhen a graph has hundreds of edges, even a good force-directed layout produces visual clutter. Edges cross each other in every direction and the structural patterns disappear into spaghetti. Edge bundling is a technique that routes nearby edges along shared paths, like cables bundled into conduits. Parallel edges merge into streams, then diverge to their individual endpoints.\n\nThe simplest approach: for each edge, instead of drawing a straight line from source to target, draw a bezier curve that bends toward the midpoint of the canvas (or toward the centroid of the graph). Edges with nearby endpoints share similar curves, creating the illusion of bundles.\n\n```javascript\n// simple edge bundling: curve edges toward center\nconst cx = 350;\nconst cy = 350;\nconst bundleStrength = 0.4;\n\nfor (const [a, b] of edges) {\n const x1 = nodes[a].x;\n const y1 = nodes[a].y;\n const x2 = nodes[b].x;\n const y2 = nodes[b].y;\n\n // control point pulled toward center\n const midX = (x1 + x2) / 2;\n const midY = (y1 + y2) / 2;\n const ctrlX = midX + (cx - midX) * bundleStrength;\n const ctrlY = midY + (cy - midY) * bundleStrength;\n\n ctx.beginPath();\n ctx.moveTo(x1, y1);\n ctx.quadraticCurveTo(ctrlX, ctrlY, x2, y2);\n ctx.strokeStyle = 'rgba(80, 140, 220, 0.08)';\n ctx.lineWidth = 1;\n ctx.stroke();\n}\n```\n\nThe `bundleStrength` parameter controls how aggressively edges are pulled toward center. At 0 you get straight lines (no bundling). At 1 all edges pass through the center (maximally bundled, but you lose endpoint information). 0.3-0.5 is usually a good range -- enough bundling to reveal flow patterns, but the individual edge endpoints are still distinguisable.\n\nWith many edges at low opacity, the bundles emerge naturally from the overlapping curves. Dense bundles -- where many edges between two clusters merge into a thick stream -- glow brightly. Isolated edges stay faint. The major highways of the network become visible while the side streets fade into the background.\n\n## Using d3-force for production layouts\n\nBuilding force simulation from scratch is educational (we just did it), but for production work you'd use d3-force. It's the gold standard force-directed layout engine, and you can import just the force module without the rest of D3. It handles all the physics, adaptive cooling, collision detection, and configurable force parameters.\n\n```javascript\n// d3-force handles layout, we handle rendering with canvas\n// import { forceSimulation, forceLink, forceManyBody, forceCenter } from 'd3-force';\n\nconst simulation = d3.forceSimulation(nodes)\n .force('charge', d3.forceManyBody().strength(-200))\n .force('link', d3.forceLink(edges).distance(60))\n .force('center', d3.forceCenter(350, 350))\n .on('tick', draw);\n\nfunction draw() {\n ctx.fillStyle = '#0a0a1a';\n ctx.fillRect(0, 0, 700, 700);\n\n // d3 updates node.x and node.y automatically\n for (const edge of edges) {\n ctx.beginPath();\n ctx.moveTo(edge.source.x, edge.source.y);\n ctx.lineTo(edge.target.x, edge.target.y);\n ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)';\n ctx.lineWidth = 1;\n ctx.stroke();\n }\n\n for (const node of nodes) {\n ctx.beginPath();\n ctx.arc(node.x, node.y, 5, 0, Math.PI * 2);\n ctx.fillStyle = 'rgba(120, 180, 255, 0.7)';\n ctx.fill();\n }\n}\n```\n\nThe key difference from our DIY version: d3-force uses adaptive cooling. The simulation starts \"hot\" (large movements each tick) and gradually cools down (smaller and smaller movements) until it stops. Our manual version used constant damping, which works but takes longer to settle. D3's cooling schedule converges faster and produces smoother results.\n\nYou don't need to use D3 for rendering -- that's the whole point. D3 calculates the positions, and you render with canvas (or p5, or WebGL, or whatever). Separation of layout from rendering. The layout engine is a tool, the visual style is yours.\n\n## Creative exercise: character co-occurrence network\n\nAllez, time to build something real. A character co-occurrence network from a novel. Two characters are connected if they appear in the same paragraph. The more paragraphs they share, the stronger the connection. Node size from total appearances, edge weight from co-occurrence count. Force-directed layout, colored by community.\n\nWe'll use a simplified dataset -- manually defining character appearances would take forever for a full novel, so we'll simulate the pattern with a fake \"book\" that has the right statistical structure.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 800;\ncanvas.height = 800;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// simulated book characters with appearance counts and co-occurrence\nconst characters = [\n { name: 'Protagonist', appearances: 180, group: 0 },\n { name: 'Mentor', appearances: 95, group: 0 },\n { name: 'Sidekick', appearances: 120, group: 0 },\n { name: 'Villain', appearances: 85, group: 1 },\n { name: 'Henchman1', appearances: 40, group: 1 },\n { name: 'Henchman2', appearances: 35, group: 1 },\n { name: 'Love Interest', appearances: 75, group: 0 },\n { name: 'Rival', appearances: 60, group: 2 },\n { name: 'Friend1', appearances: 45, group: 2 },\n { name: 'Friend2', appearances: 50, group: 2 },\n { name: 'Elder', appearances: 30, group: 0 },\n { name: 'Spy', appearances: 55, group: 1 },\n { name: 'Narrator', appearances: 70, group: 0 },\n { name: 'Merchant', appearances: 25, group: 2 },\n { name: 'Guard', appearances: 20, group: 1 }\n];\n\n// co-occurrence edges (source, target, shared paragraphs)\nconst cooccurrence = [\n [0, 1, 45], [0, 2, 70], [0, 3, 30], [0, 6, 40],\n [0, 7, 25], [0, 12, 35], [1, 2, 20], [1, 10, 15],\n [1, 12, 18], [2, 6, 15], [2, 7, 12], [3, 4, 30],\n [3, 5, 25], [3, 11, 22], [4, 5, 18], [4, 14, 12],\n [5, 14, 10], [6, 12, 8], [7, 8, 20], [7, 9, 18],\n [8, 9, 25], [8, 13, 10], [9, 13, 8], [10, 12, 6],\n [11, 3, 15], [11, 14, 8], [0, 11, 10], [2, 3, 8]\n];\n\n// initialize node positions\nconst nodes = characters.map((c, i) => ({\n x: 400 + (Math.random() - 0.5) * 200,\n y: 400 + (Math.random() - 0.5) * 200,\n vx: 0, vy: 0,\n ...c\n}));\n\n// run force simulation for 200 steps\nfor (let step = 0; step < 200; step++) {\n const cooling = 1.0 - step / 200;\n\n // repulsion\n for (let i = 0; i < nodes.length; i++) {\n for (let j = i + 1; j < nodes.length; j++) {\n const dx = nodes[j].x - nodes[i].x;\n const dy = nodes[j].y - nodes[i].y;\n const dist = Math.sqrt(dx * dx + dy * dy) + 1;\n const force = 4000 / (dist * dist);\n const fx = (dx / dist) * force * cooling;\n const fy = (dy / dist) * force * cooling;\n nodes[i].vx -= fx; nodes[i].vy -= fy;\n nodes[j].vx += fx; nodes[j].vy += fy;\n }\n }\n\n // spring attraction\n for (const [a, b, w] of cooccurrence) {\n const dx = nodes[b].x - nodes[a].x;\n const dy = nodes[b].y - nodes[a].y;\n const dist = Math.sqrt(dx * dx + dy * dy) + 1;\n const strength = 0.005 + (w / 70) * 0.01;\n const target = 70;\n const disp = dist - target;\n const fx = (dx / dist) * disp * strength * cooling;\n const fy = (dy / dist) * disp * strength * cooling;\n nodes[a].vx += fx; nodes[a].vy += fy;\n nodes[b].vx -= fx; nodes[b].vy -= fy;\n }\n\n // center + damping\n for (const node of nodes) {\n node.vx += (400 - node.x) * 0.002;\n node.vy += (400 - node.y) * 0.002;\n node.vx *= 0.8;\n node.vy *= 0.8;\n node.x += node.vx;\n node.y += node.vy;\n }\n}\n\n// draw\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 800, 800);\n\nconst groupHues = [200, 350, 130];\nconst maxApp = Math.max(...characters.map(c => c.appearances));\nconst maxW = Math.max(...cooccurrence.map(e => e[2]));\n\n// edges\nfor (const [a, b, w] of cooccurrence) {\n ctx.beginPath();\n ctx.moveTo(nodes[a].x, nodes[a].y);\n ctx.lineTo(nodes[b].x, nodes[b].y);\n ctx.strokeStyle = `rgba(100, 130, 180, ${0.05 + (w / maxW) * 0.3})`;\n ctx.lineWidth = 0.5 + (w / maxW) * 3;\n ctx.stroke();\n}\n\n// nodes\nfor (let i = 0; i < nodes.length; i++) {\n const n = nodes[i];\n const area = (n.appearances / maxApp) * 1200 + 80;\n const r = Math.sqrt(area / Math.PI);\n const hue = groupHues[n.group];\n\n // glow\n ctx.beginPath();\n ctx.arc(n.x, n.y, r + 4, 0, Math.PI * 2);\n ctx.fillStyle = `hsla(${hue}, 50%, 50%, 0.12)`;\n ctx.fill();\n\n // core\n ctx.beginPath();\n ctx.arc(n.x, n.y, r, 0, Math.PI * 2);\n ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`;\n ctx.fill();\n\n // label\n ctx.fillStyle = 'rgba(180, 190, 210, 0.5)';\n ctx.font = '9px monospace';\n ctx.textAlign = 'center';\n ctx.fillText(n.name, n.x, n.y + r + 12);\n}\n```\n\nThe Protagonist dominates the center -- highest degree, most co-occurrences, pulled toward everyone. The Villain's group (red/pink) clusters separately but connects to the Protagonist through direct encounters and through the Spy who bridges both worlds. The Friend group (green) orbits the Rival, connected to the hero side but with their own internal density. The Elder and Narrator drift near the hero cluster but at the periphery -- they appear less frequently.\n\nThis is what network visualization does that no other technique can: it reveals *relational structure*. Not \"how much\" or \"when\" or \"where\" -- but \"who connects to whom, and how tightly.\" The social architecture of a novel, visible in a single image. If you fed a real book through a paragraph co-occurrence counter (Project Gutenberg has plenty of free texts), the network would reveal the book's social structure in a way that reading the text cover-to-cover doesn't make explicit.\n\n## Radial layout: hierarchy as rings\n\nNot all graphs are flat peer-to-peer networks. Some have hierarchy -- a root node with children, grandchildren, great-grandchildren. Organizational charts, file system trees, taxonomy classifications. A radial tree layout places the root at the center and arranges each level of the hierarchy as a concentric ring.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 700;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// build a tree: root has 4 children, each has 2-4 grandchildren\nconst tree = { id: 0, children: [] };\nlet nextId = 1;\n\nfor (let i = 0; i < 4; i++) {\n const child = { id: nextId++, children: [] };\n const numGrand = 2 + Math.floor(Math.random() * 3);\n for (let j = 0; j < numGrand; j++) {\n const grand = { id: nextId++, children: [] };\n // some grandchildren have leaves\n if (Math.random() < 0.5) {\n const numLeaves = 1 + Math.floor(Math.random() * 3);\n for (let k = 0; k < numLeaves; k++) {\n grand.children.push({ id: nextId++, children: [] });\n }\n }\n child.children.push(grand);\n }\n tree.children.push(child);\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 700, 700);\n\nconst cx = 350;\nconst cy = 350;\n\nfunction drawRadialTree(node, startAngle, endAngle, depth) {\n const radius = depth * 80;\n const angle = (startAngle + endAngle) / 2;\n\n const x = cx + Math.cos(angle) * radius;\n const y = cy + Math.sin(angle) * radius;\n\n // draw edge to parent (except root)\n if (depth > 0) {\n const parentAngle = angle;\n const parentR = (depth - 1) * 80;\n const px = cx + Math.cos(parentAngle) * parentR;\n const py = cy + Math.sin(parentAngle) * parentR;\n\n ctx.beginPath();\n ctx.moveTo(px, py);\n ctx.lineTo(x, y);\n ctx.strokeStyle = `rgba(80, 120, 180, ${0.5 - depth * 0.1})`;\n ctx.lineWidth = 3 - depth * 0.5;\n ctx.stroke();\n }\n\n // draw node\n const r = depth === 0 ? 8 : 4;\n const hue = 200 + depth * 40;\n ctx.beginPath();\n ctx.arc(x, y, r, 0, Math.PI * 2);\n ctx.fillStyle = `hsla(${hue}, 50%, 55%, 0.7)`;\n ctx.fill();\n\n // recurse for children\n if (node.children.length > 0) {\n const sliceSize = (endAngle - startAngle) / node.children.length;\n for (let i = 0; i < node.children.length; i++) {\n const childStart = startAngle + i * sliceSize;\n const childEnd = childStart + sliceSize;\n drawRadialTree(node.children[i], childStart, childEnd, depth + 1);\n }\n }\n}\n\ndrawRadialTree(tree, 0, Math.PI * 2, 0);\n```\n\nThe root sits at the center. Four branches radiate outward, each claiming a quarter of the circle (roughly). Grandchildren subdivide their parent's angular range. Leaves sit at the outermost ring. The radial layout uses space efficiently -- linear tree layouts waste horizontal space because deep trees get very wide, but the radial tree wraps everything into a compact circle.\n\nThe color shifts from blue (root) to warmer tones (leaves), giving you a visual depth cue. Edge thickness decreases with depth too -- the trunk is thick, the branches thin, matching the visual metaphor of an actual tree.\n\n## What's coming\n\nWe've got geography (ep083), time (ep084), and now networks (this episode) -- three fundamentally different data structures, each with its own visual language. But most real-world data isn't neatly geographic, temporal, or relational. It's messy text -- articles, tweets, logs, books. Extracting visual patterns from text requires a different set of techniques: word frequency, sentiment analysis, linguistic structure. And text happens to be one of the richest sources of creative raw material when you know how to parse it.\n\n## 't Komt erop neer...\n\n- Networks have two elements: nodes (entities) and edges (connections). Social networks, citation graphs, hyperlinks, character co-occurrence, function call trees -- most complex systems are graphs. The visual language is node-link diagrams (circles and lines), but layout is the hard problem: where you place nodes determines whether the picture is readable or a hairball\n- Force-directed layout treats edges as springs and node pairs as repelling charges, then simulates physics until equilibrium. Connected nodes cluster together, unconnected nodes drift apart. Three forces: repulsion (all pairs, inverse square), spring attraction (edges only, Hooke's law), and centering (weak pull to canvas center). Damping drains kinetic energy so the system converges\n- Visual encoding makes nodes meaningful: size from degree (area-proportional using `Math.sqrt`), color from category or importance, edge thickness from weight. Without encoding, all nodes look the same and the graph hides its information\n- Community detection assigns colors to clusters of densely connected nodes. Label propagation is simple: each node adopts the most common label among its neighbors, iterated until convergence. Golden angle spacing (137 degrees between hues) prevents adjacent community indices from getting similar colors\n- Adjacency matrices represent edges as colored cells in a grid (row i, column j filled if there's an edge). They never have crossing edges and scale to dense graphs where node-link diagrams become hairballs. But node ordering matters critically -- random order hides the block structure, sorted order reveals it\n- Arc diagrams place nodes on a horizontal line with edges as arcs above. Arc height shows the distance between connected nodes in the linear ordering. Works beautifully for sequential data -- characters in a story, functions in source code -- where the linear order carries meaning\n- Interactive hover-and-highlight is essential for networks above ~30 nodes. Highlight one node's edges and neighbors while fading everything else. The ego-network pops out of the complexity. Same graph looks different from every node's perspective\n- Edge bundling reduces visual clutter by routing nearby edges along shared curves. Bend each edge's bezier control point toward a common attractor (graph centroid). Bundled edges at low opacity reveal major flow patterns while isolated edges fade\n- D3-force is the production standard for force layout. Import just the force module, let it compute positions, and render with canvas. Adaptive cooling converges faster than constant damping. Separation of layout engine from rendering keeps your visual style flexible\n- Radial tree layout places the root at center with each hierarchy level as a concentric ring. Children subdivide their parent's angular range. Compact and space-efficient compared to linear tree layouts. Color and line thickness shifting with depth reinforces the hierarchy visually\n\nSallukes! Thanks for reading.\n\nX\n\n@femdev\n",
"title": "Learn Creative Coding (#85) - Network and Graph Visualization",
"author": "femdev",
"permlink": "learn-creative-coding-85-network-and-graph-visualization",
"json_metadata": "{\"tags\": [\"stem\", \"stemsocial\", \"steemstem\", \"programming\", \"creativecoding\"], \"app\": \"hiveblog/0.1\", \"format\": \"markdown\", \"image\": [\"https://images.hive.blog/DQmZCVibociQsR6XsLDTrnzNBjz47h1z3ocJGFZgerPf7xM/cc-banner-red.png\"]}",
"parent_author": "",
"parent_permlink": "hive-196387"
}
],
"block": 107005628,
"trx_id": "39157976d1a0075edc574ccddecafea4ea97491f",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:47:42",
"virtual_op": false,
"trx_in_block": 5
}femdevclaimed reward balance: 0.508 HIVE, 0.519 HP2026/06/05 11:28:36
femdevclaimed reward balance: 0.508 HIVE, 0.519 HP
2026/06/05 11:28:36
| account | femdev |
| reward hbd | 0.000 HBD |
| reward hive | 0.508 HIVE |
| reward vests | 842.931561 VESTS |
| Transaction Info | Block #107005247/Trx c683d8c5bbb88d5b34f0679aaeccd4f979d91f52 |
View Raw JSON Data
{
"op": [
"claim_reward_balance",
{
"account": "femdev",
"reward_hbd": "0.000 HBD",
"reward_hive": "0.508 HIVE",
"reward_vests": "842.931561 VESTS"
}
],
"block": 107005247,
"trx_id": "c683d8c5bbb88d5b34f0679aaeccd4f979d91f52",
"op_in_trx": 0,
"timestamp": "2026-06-05T11:28:36",
"virtual_op": false,
"trx_in_block": 10
}femdevupdated payout for learn-creative-coding-78-webxr-creative-coding-in-vrar2026/06/05 11:13:24
femdevupdated payout for learn-creative-coding-78-webxr-creative-coding-in-vrar
2026/06/05 11:13:24
| author | femdev |
| permlink | learn-creative-coding-78-webxr-creative-coding-in-vrar |
| Transaction Info | Block #107004943/Virtual Operation 4294967295:14 |
View Raw JSON Data
{
"op": [
"comment_payout_update",
{
"author": "femdev",
"permlink": "learn-creative-coding-78-webxr-creative-coding-in-vrar"
}
],
"block": 107004943,
"trx_id": "0000000000000000000000000000000000000000",
"op_in_trx": 14,
"timestamp": "2026-06-05T11:13:24",
"virtual_op": true,
"trx_in_block": 4294967295
}femdevreceived 0.119 HBD reward share for learn-creative-coding-78-webxr-creative-coding-in-vrar2026/06/05 11:13:24
femdevreceived 0.119 HBD reward share for learn-creative-coding-78-webxr-creative-coding-in-vrar
2026/06/05 11:13:24
| author | femdev |
| payout | 0.119 HBD |
| permlink | learn-creative-coding-78-webxr-creative-coding-in-vrar |
| author rewards | 1016 |
| total payout value | 0.059 HBD |
| curator payout value | 0.059 HBD |
| beneficiary payout value | 0.000 HBD |
| Transaction Info | Block #107004943/Virtual Operation 4294967295:13 |
View Raw JSON Data
{
"op": [
"comment_reward",
{
"author": "femdev",
"payout": "0.119 HBD",
"permlink": "learn-creative-coding-78-webxr-creative-coding-in-vrar",
"author_rewards": 1016,
"total_payout_value": "0.059 HBD",
"curator_payout_value": "0.059 HBD",
"beneficiary_payout_value": "0.000 HBD"
}
],
"block": 107004943,
"trx_id": "0000000000000000000000000000000000000000",
"op_in_trx": 13,
"timestamp": "2026-06-05T11:13:24",
"virtual_op": true,
"trx_in_block": 4294967295
}femdevreceived 0.508 HIVE, 0.508 HP author reward for @femdev / learn-creative-coding-78-webxr-creative-coding-in-vrar2026/06/05 11:13:24
femdevreceived 0.508 HIVE, 0.508 HP author reward for @femdev / learn-creative-coding-78-webxr-creative-coding-in-vrar
2026/06/05 11:13:24
| author | femdev |
| permlink | learn-creative-coding-78-webxr-creative-coding-in-vrar |
| hbd payout | 0.000 HBD |
| hive payout | 0.508 HIVE |
| vesting payout | 825.065960 VESTS |
| payout must be claimed | true |
| curators vesting payout | 1643.635337 VESTS |
| Transaction Info | Block #107004943/Virtual Operation 4294967295:12 |
View Raw JSON Data
{
"op": [
"author_reward",
{
"author": "femdev",
"permlink": "learn-creative-coding-78-webxr-creative-coding-in-vrar",
"hbd_payout": "0.000 HBD",
"hive_payout": "0.508 HIVE",
"vesting_payout": "825.065960 VESTS",
"payout_must_be_claimed": true,
"curators_vesting_payout": "1643.635337 VESTS"
}
],
"block": 107004943,
"trx_id": "0000000000000000000000000000000000000000",
"op_in_trx": 12,
"timestamp": "2026-06-05T11:13:24",
"virtual_op": true,
"trx_in_block": 4294967295
}femdevreceived 0.011 HP curation reward for @femdev / learn-creative-coding-78-webxr-creative-coding-in-vrar2026/06/05 11:13:24
femdevreceived 0.011 HP curation reward for @femdev / learn-creative-coding-78-webxr-creative-coding-in-vrar
2026/06/05 11:13:24
| author | femdev |
| reward | 17.865601 VESTS |
| curator | femdev |
| permlink | learn-creative-coding-78-webxr-creative-coding-in-vrar |
| payout must be claimed | true |
| Transaction Info | Block #107004943/Virtual Operation 4294967295:6 |
View Raw JSON Data
{
"op": [
"curation_reward",
{
"author": "femdev",
"reward": "17.865601 VESTS",
"curator": "femdev",
"permlink": "learn-creative-coding-78-webxr-creative-coding-in-vrar",
"payout_must_be_claimed": true
}
],
"block": 107004943,
"trx_id": "0000000000000000000000000000000000000000",
"op_in_trx": 6,
"timestamp": "2026-06-05T11:13:24",
"virtual_op": true,
"trx_in_block": 4294967295
}femdevclaimed reward balance: 0.507 HIVE, 0.518 HP2026/06/04 16:26:51
femdevclaimed reward balance: 0.507 HIVE, 0.518 HP
2026/06/04 16:26:51
| account | femdev |
| reward hbd | 0.000 HBD |
| reward hive | 0.507 HIVE |
| reward vests | 841.368926 VESTS |
| Transaction Info | Block #106982470/Trx f90dba46d12fca57e3694d0d41996177136b52fd |
View Raw JSON Data
{
"op": [
"claim_reward_balance",
{
"account": "femdev",
"reward_hbd": "0.000 HBD",
"reward_hive": "0.507 HIVE",
"reward_vests": "841.368926 VESTS"
}
],
"block": 106982470,
"trx_id": "f90dba46d12fca57e3694d0d41996177136b52fd",
"op_in_trx": 0,
"timestamp": "2026-06-04T16:26:51",
"virtual_op": false,
"trx_in_block": 0
}stem-shturmeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 14:52:12
stem-shturmeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 14:52:12
| voter | stem-shturm |
| author | femdev |
| weight | 133577198 |
| rshares | 133577198 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.123 HBD |
| total vote weight | 1575900307147 |
| Transaction Info | Block #106980581/Trx 1fe9f29fc5d163bb7510df6c2f64826cf92ec9f8 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "stem-shturm",
"author": "femdev",
"weight": 133577198,
"rshares": 133577198,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.123 HBD",
"total_vote_weight": 1575900307147
}
],
"block": 106980581,
"trx_id": "1fe9f29fc5d163bb7510df6c2f64826cf92ec9f8",
"op_in_trx": 1,
"timestamp": "2026-06-04T14:52:12",
"virtual_op": true,
"trx_in_block": 14
}stem-shturmupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 14:52:12
stem-shturmupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 14:52:12
| voter | stem-shturm |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106980581/Trx 1fe9f29fc5d163bb7510df6c2f64826cf92ec9f8 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "stem-shturm",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106980581,
"trx_id": "1fe9f29fc5d163bb7510df6c2f64826cf92ec9f8",
"op_in_trx": 0,
"timestamp": "2026-06-04T14:52:12",
"virtual_op": false,
"trx_in_block": 14
}hive-103505effective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:47:39
hive-103505effective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:47:39
| voter | hive-103505 |
| author | femdev |
| weight | 1803458840 |
| rshares | 1803458840 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.125 HBD |
| total vote weight | 1575766729949 |
| Transaction Info | Block #106978097/Trx 06729e812668ffd06a68ea01e403bf9904a9338f |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "hive-103505",
"author": "femdev",
"weight": 1803458840,
"rshares": 1803458840,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.125 HBD",
"total_vote_weight": 1575766729949
}
],
"block": 106978097,
"trx_id": "06729e812668ffd06a68ea01e403bf9904a9338f",
"op_in_trx": 3,
"timestamp": "2026-06-04T12:47:39",
"virtual_op": true,
"trx_in_block": 0
}hive-103505upvoted (3.50%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:47:39
hive-103505upvoted (3.50%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:47:39
| voter | hive-103505 |
| author | femdev |
| weight | 350 (3.50%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106978097/Trx 06729e812668ffd06a68ea01e403bf9904a9338f |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "hive-103505",
"author": "femdev",
"weight": 350,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106978097,
"trx_id": "06729e812668ffd06a68ea01e403bf9904a9338f",
"op_in_trx": 2,
"timestamp": "2026-06-04T12:47:39",
"virtual_op": false,
"trx_in_block": 0
}psychophiloeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:47:39
psychophiloeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:47:39
| voter | psychophilo |
| author | femdev |
| weight | 7321255 |
| rshares | 7321255 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.124 HBD |
| total vote weight | 1573963271109 |
| Transaction Info | Block #106978097/Trx 06729e812668ffd06a68ea01e403bf9904a9338f |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "psychophilo",
"author": "femdev",
"weight": 7321255,
"rshares": 7321255,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.124 HBD",
"total_vote_weight": 1573963271109
}
],
"block": 106978097,
"trx_id": "06729e812668ffd06a68ea01e403bf9904a9338f",
"op_in_trx": 1,
"timestamp": "2026-06-04T12:47:39",
"virtual_op": true,
"trx_in_block": 0
}psychophiloupvoted (3.50%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:47:39
psychophiloupvoted (3.50%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:47:39
| voter | psychophilo |
| author | femdev |
| weight | 350 (3.50%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106978097/Trx 06729e812668ffd06a68ea01e403bf9904a9338f |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "psychophilo",
"author": "femdev",
"weight": 350,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106978097,
"trx_id": "06729e812668ffd06a68ea01e403bf9904a9338f",
"op_in_trx": 0,
"timestamp": "2026-06-04T12:47:39",
"virtual_op": false,
"trx_in_block": 0
}we-are-aieffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:47:30
we-are-aieffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:47:30
| voter | we-are-ai |
| author | femdev |
| weight | 22815995045 |
| rshares | 22815995045 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.124 HBD |
| total vote weight | 1573955949854 |
| Transaction Info | Block #106978094/Trx a6126fa0abf44acff58a6075b44706ed00d4db50 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "we-are-ai",
"author": "femdev",
"weight": 22815995045,
"rshares": 22815995045,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.124 HBD",
"total_vote_weight": 1573955949854
}
],
"block": 106978094,
"trx_id": "a6126fa0abf44acff58a6075b44706ed00d4db50",
"op_in_trx": 1,
"timestamp": "2026-06-04T12:47:30",
"virtual_op": true,
"trx_in_block": 3
}we-are-aiupvoted (3.50%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:47:30
we-are-aiupvoted (3.50%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:47:30
| voter | we-are-ai |
| author | femdev |
| weight | 350 (3.50%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106978094/Trx a6126fa0abf44acff58a6075b44706ed00d4db50 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "we-are-ai",
"author": "femdev",
"weight": 350,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106978094,
"trx_id": "a6126fa0abf44acff58a6075b44706ed00d4db50",
"op_in_trx": 0,
"timestamp": "2026-06-04T12:47:30",
"virtual_op": false,
"trx_in_block": 3
}jeronimorubioeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:43:57
jeronimorubioeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:43:57
| voter | jeronimorubio |
| author | femdev |
| weight | 11162786581 |
| rshares | 11162786581 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.123 HBD |
| total vote weight | 1551139954809 |
| Transaction Info | Block #106978023/Trx 63c540c5f86de539e69144a79698bb9c7971de30 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "jeronimorubio",
"author": "femdev",
"weight": 11162786581,
"rshares": 11162786581,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.123 HBD",
"total_vote_weight": 1551139954809
}
],
"block": 106978023,
"trx_id": "63c540c5f86de539e69144a79698bb9c7971de30",
"op_in_trx": 1,
"timestamp": "2026-06-04T12:43:57",
"virtual_op": true,
"trx_in_block": 1
}jeronimorubioupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:43:57
jeronimorubioupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:43:57
| voter | jeronimorubio |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106978023/Trx 63c540c5f86de539e69144a79698bb9c7971de30 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "jeronimorubio",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106978023,
"trx_id": "63c540c5f86de539e69144a79698bb9c7971de30",
"op_in_trx": 0,
"timestamp": "2026-06-04T12:43:57",
"virtual_op": false,
"trx_in_block": 1
}coinmarketcaleffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:33:09
coinmarketcaleffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:33:09
| voter | coinmarketcal |
| author | femdev |
| weight | 5295191798 |
| rshares | 5295191798 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.122 HBD |
| total vote weight | 1539977168228 |
| Transaction Info | Block #106977808/Trx b88ceb896133cd23062ebe807043a03c9473c32e |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "coinmarketcal",
"author": "femdev",
"weight": 5295191798,
"rshares": 5295191798,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.122 HBD",
"total_vote_weight": 1539977168228
}
],
"block": 106977808,
"trx_id": "b88ceb896133cd23062ebe807043a03c9473c32e",
"op_in_trx": 1,
"timestamp": "2026-06-04T12:33:09",
"virtual_op": true,
"trx_in_block": 1
}coinmarketcalupvoted (22.00%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:33:09
coinmarketcalupvoted (22.00%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:33:09
| voter | coinmarketcal |
| author | femdev |
| weight | 2200 (22.00%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106977808/Trx b88ceb896133cd23062ebe807043a03c9473c32e |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "coinmarketcal",
"author": "femdev",
"weight": 2200,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106977808,
"trx_id": "b88ceb896133cd23062ebe807043a03c9473c32e",
"op_in_trx": 0,
"timestamp": "2026-06-04T12:33:09",
"virtual_op": false,
"trx_in_block": 1
}newsrxeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:28:57
newsrxeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:28:57
| voter | newsrx |
| author | femdev |
| weight | 2060802963 |
| rshares | 2060802963 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.121 HBD |
| total vote weight | 1534681976430 |
| Transaction Info | Block #106977724/Trx f349a192e0facaeedda5edf400c48c8219bbb048 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "newsrx",
"author": "femdev",
"weight": 2060802963,
"rshares": 2060802963,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.121 HBD",
"total_vote_weight": 1534681976430
}
],
"block": 106977724,
"trx_id": "f349a192e0facaeedda5edf400c48c8219bbb048",
"op_in_trx": 1,
"timestamp": "2026-06-04T12:28:57",
"virtual_op": true,
"trx_in_block": 22
}newsrxupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:28:57
newsrxupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:28:57
| voter | newsrx |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106977724/Trx f349a192e0facaeedda5edf400c48c8219bbb048 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "newsrx",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106977724,
"trx_id": "f349a192e0facaeedda5edf400c48c8219bbb048",
"op_in_trx": 0,
"timestamp": "2026-06-04T12:28:57",
"virtual_op": false,
"trx_in_block": 22
}blue-witnesseffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:28:42
blue-witnesseffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:28:42
| voter | blue-witness |
| author | femdev |
| weight | 3621524313 |
| rshares | 3621524313 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.121 HBD |
| total vote weight | 1532621173467 |
| Transaction Info | Block #106977719/Trx fed96c728e8b8a22bdf0b3f362bba84ac32fd001 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "blue-witness",
"author": "femdev",
"weight": 3621524313,
"rshares": 3621524313,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.121 HBD",
"total_vote_weight": 1532621173467
}
],
"block": 106977719,
"trx_id": "fed96c728e8b8a22bdf0b3f362bba84ac32fd001",
"op_in_trx": 1,
"timestamp": "2026-06-04T12:28:42",
"virtual_op": true,
"trx_in_block": 15
}blue-witnessupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:28:42
blue-witnessupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:28:42
| voter | blue-witness |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106977719/Trx fed96c728e8b8a22bdf0b3f362bba84ac32fd001 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "blue-witness",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106977719,
"trx_id": "fed96c728e8b8a22bdf0b3f362bba84ac32fd001",
"op_in_trx": 0,
"timestamp": "2026-06-04T12:28:42",
"virtual_op": false,
"trx_in_block": 15
}steem-uaeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:28:24
steem-uaeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:28:24
| voter | steem-ua |
| author | femdev |
| weight | 513288209377 |
| rshares | 513288209377 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.121 HBD |
| total vote weight | 1528999649154 |
| Transaction Info | Block #106977713/Trx 7e262a0debe013c427be72fff7a5529da17a61ba |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "steem-ua",
"author": "femdev",
"weight": 513288209377,
"rshares": 513288209377,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.121 HBD",
"total_vote_weight": 1528999649154
}
],
"block": 106977713,
"trx_id": "7e262a0debe013c427be72fff7a5529da17a61ba",
"op_in_trx": 1,
"timestamp": "2026-06-04T12:28:24",
"virtual_op": true,
"trx_in_block": 13
}steem-uaupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:28:24
steem-uaupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:28:24
| voter | steem-ua |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106977713/Trx 7e262a0debe013c427be72fff7a5529da17a61ba |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "steem-ua",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106977713,
"trx_id": "7e262a0debe013c427be72fff7a5529da17a61ba",
"op_in_trx": 0,
"timestamp": "2026-06-04T12:28:24",
"virtual_op": false,
"trx_in_block": 13
}scipioeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:28:18
scipioeffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:28:18
| voter | scipio |
| author | femdev |
| weight | 199932102555 |
| rshares | 199932102555 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.080 HBD |
| total vote weight | 1015711439777 |
| Transaction Info | Block #106977711/Trx 00b64bb1c653adea87b5b6b6d5433462f3274866 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "scipio",
"author": "femdev",
"weight": 199932102555,
"rshares": 199932102555,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.080 HBD",
"total_vote_weight": 1015711439777
}
],
"block": 106977711,
"trx_id": "00b64bb1c653adea87b5b6b6d5433462f3274866",
"op_in_trx": 1,
"timestamp": "2026-06-04T12:28:18",
"virtual_op": true,
"trx_in_block": 27
}scipioupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:28:18
scipioupvoted (100.00%) @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:28:18
| voter | scipio |
| author | femdev |
| weight | 10000 (100.00%) |
| permlink | learn-creative-coding-84-visualizing-time |
| Transaction Info | Block #106977711/Trx 00b64bb1c653adea87b5b6b6d5433462f3274866 |
View Raw JSON Data
{
"op": [
"vote",
{
"voter": "scipio",
"author": "femdev",
"weight": 10000,
"permlink": "learn-creative-coding-84-visualizing-time"
}
],
"block": 106977711,
"trx_id": "00b64bb1c653adea87b5b6b6d5433462f3274866",
"op_in_trx": 0,
"timestamp": "2026-06-04T12:28:18",
"virtual_op": false,
"trx_in_block": 27
}fumegieffective vote applied for @femdev / learn-creative-coding-84-visualizing-time2026/06/04 12:28:15
fumegieffective vote applied for @femdev / learn-creative-coding-84-visualizing-time
2026/06/04 12:28:15
| voter | fumegi |
| author | femdev |
| weight | 3197155620 |
| rshares | 3197155620 |
| permlink | learn-creative-coding-84-visualizing-time |
| pending payout | 0.064 HBD |
| total vote weight | 815779337222 |
| Transaction Info | Block #106977710/Trx e85377072294bf8043619bf1e2e10067524112b9 |
View Raw JSON Data
{
"op": [
"effective_comment_vote",
{
"voter": "fumegi",
"author": "femdev",
"weight": 3197155620,
"rshares": 3197155620,
"permlink": "learn-creative-coding-84-visualizing-time",
"pending_payout": "0.064 HBD",
"total_vote_weight": 815779337222
}
],
"block": 106977710,
"trx_id": "e85377072294bf8043619bf1e2e10067524112b9",
"op_in_trx": 1,
"timestamp": "2026-06-04T12:28:15",
"virtual_op": true,
"trx_in_block": 11
}Manabar
Voting Power100.00%
Downvote Power100.00%
Resource Credits100.00%
Reputation Progress0.00%
{
"voting_manabar": {
"current_mana": 857550618114,
"last_update_time": 1780751157
},
"downvote_manabar": {
"current_mana": 218374241677,
"last_update_time": 1780751157
},
"rc_account": {
"account": "femdev",
"rc_manabar": {
"current_mana": 857976684903,
"last_update_time": 1780751157
},
"max_rc_creation_adjustment": {
"amount": "2020748973",
"precision": 6,
"nai": "@@000000037"
},
"max_rc": 875517715685,
"delegated_rc": 0,
"received_delegated_rc": 0
}
}Account Metadata
| POSTING JSON METADATA | |
| profile | {"profile_image":"https://iili.io/qJA9iv4.png","cover_image":"https://iili.io/qJA5IwB.jpg","name":"femdev","about":"Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.","location":"Antwerpen, Belgium"} |
| JSON METADATA | |
| profile | {"profile_image":"https://iili.io/qJA9iv4.png","cover_image":"https://iili.io/qJA5IwB.jpg","name":"femdev","about":"Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.","location":"Antwerpen, Belgium"} |
{
"posting_json_metadata": {
"profile": {
"profile_image": "https://iili.io/qJA9iv4.png",
"cover_image": "https://iili.io/qJA5IwB.jpg",
"name": "femdev",
"about": "Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.",
"location": "Antwerpen, Belgium"
}
},
"json_metadata": {
"profile": {
"profile_image": "https://iili.io/qJA9iv4.png",
"cover_image": "https://iili.io/qJA5IwB.jpg",
"name": "femdev",
"about": "Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.",
"location": "Antwerpen, Belgium"
}
}
}Auth Keys
Owner
Single Signature
Public Keys
STM7KZKwyibAFkYc2jb6gLZLQjPDyVuzfjnS5rU5hrurcK5fyu2Bj1/1
Active
Single Signature
Public Keys
STM7RmcM9PgA3GPg8dcPzL7LzoiSvZnUsFX4rDjoXfYVcXx4cHBqX1/1
Posting
Single Signature
Public Keys
STM7vHX7tx89iw5VMg3JmQwV4p2SV9npWmhPpWfGtUpEuaSy2GJjT1/1
Memo
STM8SuHVNJePs4kLBZarbJvzYsvenp8CVJ2gQEcJRhUoqbHuTDMps
{
"owner": {
"weight_threshold": 1,
"account_auths": [],
"key_auths": [
[
"STM7KZKwyibAFkYc2jb6gLZLQjPDyVuzfjnS5rU5hrurcK5fyu2Bj",
1
]
]
},
"active": {
"weight_threshold": 1,
"account_auths": [],
"key_auths": [
[
"STM7RmcM9PgA3GPg8dcPzL7LzoiSvZnUsFX4rDjoXfYVcXx4cHBqX",
1
]
]
},
"posting": {
"weight_threshold": 1,
"account_auths": [],
"key_auths": [
[
"STM7vHX7tx89iw5VMg3JmQwV4p2SV9npWmhPpWfGtUpEuaSy2GJjT",
1
]
]
},
"memo": "STM8SuHVNJePs4kLBZarbJvzYsvenp8CVJ2gQEcJRhUoqbHuTDMps"
}Witness Votes
0 / 30
No active witness votes.
[]