File:A Hornblower Chronology.svg
Original file (SVG file, nominally 940 × 1,250 pixels, file size: 63 KB)
Captions
Summary[edit]
DescriptionA Hornblower Chronology.svg |
English: A chronology of the Hornblower stories by C. S. Forester, showing timelines of the publication and plots of each, and of Hornblower's rank. Novels are numbered in order of the dates of the described action and connected together to show the sequence of publication. Short stories are labelled with letters, in a smaller font, and are connected with dashed lines. |
Date | |
Source | Own work |
Author | Jaa101 |
Code to Generate the SVG[edit]
The following Python3 code generates the SVG and is provided here under the same licence. The easiest way to update the SVG will be to modify the code and rerun Python. After doing so, also update the following code, including the version timestamp.
#
# Used to create the wikimedia version of
# https://commons.wikimedia.org/wiki/File:A_Hornblower_Chronology.svg
# timestamped 15:34, 14 February 2023
# Licensed under the same terms as the generated file on wikimedia.
#
import svgwrite
import datetime
pubYears = [*range(1937, 1967)] # years of publication
discontinuity = 1824
actYears = [*range(1794, discontinuity + 1), 1848] # years of described action
# 17 Hornblower novels and short stories with abbreviated titles
# [ Title, novel?, publication date, action start date, action end date, action skew, publication skew ]
pubs = [
['Midshipman', True, [1950, 3, 13], [1794, 1, 2], [1799, 2, 25], 0, 0],
['Hand of Destiny', False, [1940, 11, 19], [1796, 11, 8], [1796, 11, 15], 8, -3],
['Widow McCool', False, [1950, 12, 5], [1799, 11, 1], [1800, 7, 28], 0, 0],
['Lieutenant', True, [1952, 2, 11], [1800, 8, 25], [1803, 3, 9], 0, 0],
['Hotspur', True, [1962, 7, 27], [1803, 4, 2], [1805, 5, 15], 0, 0],
['Crisis', True, [1966, 7, 26], [1805, 5, 17], [1805, 6, 4], 0, 3],
['Atropos', True, [1953, 9, 10], [1805, 12, 23], [1806, 9, 29], 0, 0],
['Happy Return', True, [1937, 2, 4], [1808, 8, 15], [1809, 1, 31], 0, 0],
['Ship of the Line', True, [1938, 3, 18], [1810, 4, 25], [1810, 11, 19], 0, 0],
['Charitable Offering', False, [1941, 1, 14], [1810, 7, 12], [1810, 7, 13], -13, 5],
['Flying Colours', True, [1938, 10, 31], [1810, 12, 3], [1811, 6, 16], 0, 0],
['Commodore', True, [1945, 3, 12], [1812, 4, 11], [1812, 12, 24], 0, 0],
['His Majesty', False, [1940, 3, 19], [1813, 4, 15], [1813, 4, 16], 0, -1],
['Lord', True, [1946, 6, 11], [1813, 10, 16], [1815, 6, 20], 0, 0],
['Point and the Edge', False, [1964, 9, 28], [1819, 8, 25], [1819, 8, 25], 0, 0],
['West Indies', True, [1958, 8, 28], [1821, 5, 4], [1823, 7, 7], 0, 0],
['Last Encounter', False, [1966, 5, 8], [1848, 9, 23], [1848, 12, 23], 0, -3],
]
# Rank, Date appointed, date promoted/demoted from
ranks = [
[['Midshipman'], [1794, 1, 2], [1796, 10, 25]],
[['Acting', 'Lieutenant'], [1796, 10, 25], [1797, 8, 16]],
[['Lieutenant'], [1797, 8, 16], [1800, 10, 31]],
[['Acting', 'Commander'], [1800, 10, 31], [1802, 10, 1]],
[['Lieutenant'], [1802, 10, 1], [1803, 3, 9]],
[['Commander'], [1803, 3, 9], [1805, 6, 7]],
[['Captain'], [1805, 6, 7], [1820, 5, 4]],
[['Admiral'], [1820, 5, 4], [1850, 4, 1]],
]
# key dimensions
sep = 5 # gap between elements
width = 940
height = 1250
ranksWidth = 90
braceWidth = 25
plotWidth = 60
storyWidth = 150
pubHeight = 50
labelHeight = 50
novelRadius = 11.5 # radius of circles used for novels
shortScale = 0.75 # short stories scaled smaller than novels
baseline = 14 # baseline separation at default font size
xHeight = 4.8 # baseline shift to centre text vertically at default font size
graphWidth = width - ranksWidth - plotWidth - storyWidth - 2 * braceWidth - 7 * sep
graphHeight = height - pubHeight - labelHeight - 4 * sep
pxPerYear = 30
pxPerPubYear = (graphWidth - 2 * novelRadius) / len(pubYears)
pxPerActYear = (graphHeight - 2 * novelRadius) / len(actYears)
daysPerYear = 365.2425
pubOrigin = datetime.date(pubYears[0], 1, 1)
def pubDateX(year, month, day):
return (datetime.date(year, month, day) - pubOrigin).days / daysPerYear * pxPerPubYear
actOrigin = datetime.date(actYears[0], 1, 1)
def actDateY(year, month, day):
t = (datetime.date(year, month, day) - actOrigin).days / daysPerYear
if year > actYears[-2]: # handle action year discontinuity leaving a one-year gap
t += actYears[-2] - actYears[-1] + 1
return t * pxPerActYear
background = '#f9f9f9'
dwg = svgwrite.Drawing('A_Hornblower_Chronology.svg', (width, height), profile='full', debug=True)
dwg.defs.add(dwg.style('''
text {font-family:sans-serif}
line {stroke:black}
circle {stroke:black; fill:white}
'''))
p = dwg.defs.add(dwg.clipPath(id='clip_path_1'))
p.add(dwg.rect((0, 0), (width, height - sep)))
clipped = svgwrite.container.Group(id='clipped', clip_path='url(#clip_path_1)')
dwg.add(clipped)
clipped.add(dwg.rect((0, 0), (width, height), fill=background))
graphTop = pubHeight + labelHeight + 3 * sep + novelRadius
graphLeft = ranksWidth + braceWidth * 2 + plotWidth + storyWidth + sep * 6 + novelRadius
leftBraceG = svgwrite.container.Group(id='leftBrace')
leftBraceG.translate(ranksWidth + braceWidth + sep * 2, graphTop)
leftBraceG.scale((-1, 1))
clipped.add(leftBraceG)
plotG = svgwrite.container.Group(id='plot')
plotG.update({'text-anchor': 'middle'})
plotG.translate(ranksWidth + braceWidth + sep * 3, graphTop)
clipped.add(plotG)
rightBraceG = svgwrite.container.Group(id='rightBrace')
rightBraceG.translate(ranksWidth + braceWidth + plotWidth + sep * 4, graphTop)
clipped.add(rightBraceG)
storyG = svgwrite.container.Group(id='story')
storyG.update({'text-anchor': 'start'})
storyG.translate(ranksWidth + braceWidth * 2 + plotWidth + sep * 5, graphTop)
clipped.add(storyG)
pubG = svgwrite.container.Group(id='pub')
pubG.update({'text-anchor': 'start'})
pubG.translate((graphLeft, pubHeight + sep))
clipped.add(pubG)
labelG = svgwrite.container.Group(id='label')
labelG.update({'text-anchor': 'middle'})
labelG.translate((graphLeft, pubHeight + sep * 2))
clipped.add(labelG)
graphG = svgwrite.container.Group(id='graph')
graphG.update({'text-anchor': 'middle'})
graphG.translate((graphLeft, graphTop))
clipped.add(graphG)
ranksG = svgwrite.container.Group(id='ranks')
ranksG.update({'text-anchor': 'end'})
ranksG.translate(ranksWidth + sep, graphTop)
clipped.add(ranksG)
arrowhead = dwg.polyline([(0, 1.5), (4, 0), (0, -1.5)])
arrow = dwg.marker(insert=(7.4, 0), size=(15, 15), orient='auto')
arrow.viewbox(minx=-5, miny=-5, width=10, height=10)
arrow.add(arrowhead)
dwg.defs.add(arrow)
arrowShort = dwg.marker(insert=(7.8, 0), size=(20, 20), orient='auto')
arrowShort.viewbox(minx=-5, miny=-5, width=10, height=10)
arrowShort.add(arrowhead)
dwg.defs.add(arrowShort)
arrowShort2 = dwg.marker(insert=(9.3, 0), size=(20, 20), orient='auto')
arrowShort2.viewbox(minx=-5, miny=-5, width=10, height=10)
arrowShort2.add(arrowhead)
dwg.defs.add(arrowShort2)
arrowTip = dwg.marker(insert=(0, 0), size=(30, 30), orient='auto')
arrowTip.viewbox(minx=-5, miny=-5, width=10, height=10)
arrowTip.add(arrowhead)
dwg.defs.add(arrowTip)
# Headings
fontScale = 1.25
fontWeight = 'bolder'
def heading(lines, x, y, group, align=None, scale=fontScale):
for line in lines:
label = dwg.text(line, font_weight=fontWeight)
if not align is None:
label.update({'text-anchor': align})
label.translate(x, y)
label.scale(scale)
group.add(label)
y += baseline * scale
heading(['Hornblower', 'Stories'], plotWidth * 0.5, -93, plotG, scale=1.8)
heading(['', 'Ranks'], 0, -30, ranksG)
heading(['Plot', 'Timeline'], plotWidth * 0.5, -30, plotG)
heading(['UK', 'Titles'], 0, -30, storyG)
heading(['(Abbreviated)'], 0, 3, storyG, scale=1)
heading(['Publication', 'Timeline'], -10, -20, pubG, 'end')
yearTick = 20
halfTick = 10
quarterTick = 6
for pub in [*pubYears, pubYears[-1]+1]:
def xyears():
for month in range(1, 13):
x = pubDateX(pub, month, 1)
if month % 12 == 1:
pubG.add(dwg.line((x, -yearTick), (x, 0)))
elif pubYears.count(pub) == 0:
return
elif month % 6 == 1:
pubG.add(dwg.line((x, -halfTick), (x, 0)))
pubYear = dwg.text(str(pub))
pubYear.translate((x + xHeight, -sep - halfTick))
pubYear.rotate(-90)
pubG.add(pubYear)
elif month % 3 == 1:
pubG.add(dwg.line((x, -quarterTick), (x, 0)))
xyears()
for act in [*actYears, actYears[-1] + 1]:
def yyears():
for month in range(1, 13):
y = actDateY(act, month, 1)
if month % 12 == 1:
plotG.add(dwg.line((0, y), (plotWidth, y)))
elif act > actYears[-1]:
return # end after marking the start of the year after the last
elif month % 6 == 1:
plotG.add(dwg.line((0, y), (halfTick, y)))
plotG.add(dwg.line((plotWidth - halfTick, y), (plotWidth, y)))
if act != discontinuity:
actYear = dwg.text(str(act), (0.5 * plotWidth, y + xHeight))
plotG.add(actYear)
elif month % 3 == 1:
plotG.add(dwg.line((0, y), (quarterTick, y)))
plotG.add(dwg.line((plotWidth - quarterTick, y), (plotWidth, y)))
yyears()
pubs.sort(key=lambda pub : datetime.date(*pub[3])) # sort by action date
novelLabel=1
shortLabel='A'
for pub in pubs: # append label letter and (x, y) coordinates to each story
if pub[1]: # Arabic number labels for novels, capital letters for short stories
pub.append(str(novelLabel))
novelLabel += 1
else:
pub.append(shortLabel)
shortLabel = chr(ord(shortLabel) + 1)
pub.append([pubDateX(*pub[2]), (actDateY(*pub[3]) + actDateY(*pub[4])) * 0.5])
def stackText(lines, x, y):
dy = baseline
y -= dy * 0.5 * (len(lines) - 1)
y += xHeight
for line in lines:
ranksG.add(dwg.text(line, (x, y)))
y += dy
zigzagWidth = braceWidth * 2 + plotWidth + storyWidth + graphWidth + sep * 4
zigs = zigzagWidth / 10 # how many zigs give 10-pixel zigs?
zigs = round((zigs + 1) * 0.5) * 2 - 1 # nearest whole odd number of zigs
zig = zigzagWidth / zigs # exact width of zigs needed
ps = f'M 0 {actDateY(1824, 7, 1)} l '
ps += f'{0.5*zig} {-0.5*zig} '
ps += f'{zig} {zig} {zig} {-zig} ' * (zigs // 2)
ps += f'{0.5*zig} {0.5*zig} '
zigzag = dwg.path(ps, stroke_width=4)
zigzag.fill('none')
zigzag.stroke('black')
ranksG.add(zigzag)
zigzag = dwg.path(ps, stroke_width=2)
zigzag.fill('none')
zigzag.stroke(background)
ranksG.add(zigzag)
for rank in ranks:
braceLeft = actDateY(*rank[1])
braceRight = actDateY(*rank[2])
width = braceRight - braceLeft
mid = 0.5 * width
ps = f'M 0 0 '
ps += f'C {0.3*braceWidth} {0.0*mid} {0.5*braceWidth} {0.3*mid} {0.5*braceWidth} {0.5*mid} '
ps += f'C {0.5*braceWidth} {0.8*mid} {0.7*braceWidth} {1.0*mid} {braceWidth} {1.0*mid} '
stackText(rank[0], 0, braceLeft + mid)
ps += f'C {0.7*braceWidth} {width-1.0*mid} {0.5*braceWidth} {width-0.8*mid} {0.5*braceWidth} {width-0.5*mid} '
ps += f'C {0.5*braceWidth} {width-0.3*mid} {0.3*braceWidth} {width-0.0*mid} 0 {width} '
b = dwg.path(ps, stroke_width=2)
b.translate((0, braceLeft))
b.fill('none')
b.stroke('black')
leftBraceG.add(b)
def genBrace(pub, bw):
braceLeft = actDateY(*pub[3])
braceRight = actDateY(*pub[4])
width = braceRight - braceLeft
if pub[5] == 0.5:
ps = f'M 30 0 L {storyWidth} 0'
else:
ps = f'M 30 {pub[5]} '
ps += f'L {0.8*storyWidth} {pub[5]} '
ps += f'C {0.9*storyWidth} {pub[5]} {0.9*storyWidth} 0 {1.0*storyWidth} 0 '
arw = b = dwg.path(ps, stroke_width=0.7)
arw.translate((0, braceLeft + 0.5 * width))
arw.fill('none')
arw.stroke('black')
arw.set_markers((None, None, arrowTip))
storyG.add(arw)
mid = width * 0.5 + pub[5]
ps = f'M 0 0 '
ps += f'C {0.3*bw} {0.0*mid} {0.5*bw} {0.3*mid} {0.5*bw} {0.5*mid} '
ps += f'C {0.5*bw} {0.8*mid} {0.7*bw} {1.0*mid} {bw} {1.0*mid} '
fs = 1 if pub[1] else shortScale
storyG.add(dwg.text(pub[7]+'. '+pub[0], (0, braceLeft + mid + fs * xHeight), font_size=f'{round(fs * 100)}%', stroke_width=7, stroke=background, stroke_linejoin='round'))
storyG.add(dwg.text(pub[7]+'. '+pub[0], (0, braceLeft + mid + fs * xHeight), font_size=f'{round(fs * 100)}%'))
mid = width * 0.5 - pub[5]
ps += f'C {0.7*bw} {width-1.0*mid} {0.5*bw} {width-0.8*mid} {0.5*bw} {width-0.5*mid} '
ps += f'C {0.5*bw} {width-0.3*mid} {0.3*bw} {width-0.0*mid} 0 {width} '
b = dwg.path(ps, stroke_width=2)
b.translate((0, braceLeft))
b.fill('none')
b.stroke('black')
rightBraceG.add(b)
def genPubArrow(pub):
x = pubDateX(*pub[2])
if pub[6] == 0:
ps = f'M 0 0 L 0 {labelHeight}'
else:
ps = f'M 0 0 '
ps += f'C 0 {0.15*labelHeight} {pub[6]} {0.15*labelHeight} {pub[6]} {0.3*labelHeight} '
ps += f'L {pub[6]} {0.7*labelHeight} '
ps += f'C {pub[6]} {0.85*labelHeight} 0 {0.85*labelHeight} 0 {1.0*labelHeight} '
arw = b = dwg.path(ps, stroke_width=0.7)
arw.translate((x, 0))
arw.fill('none')
arw.stroke('black')
arw.set_markers((None, None, arrowTip))
labelG.add(arw)
x += pub[6]
labelG.add(dwg.text(pub[7], (x, 0.5 * labelHeight + xHeight * 0.8), font_size='80%', stroke_width=7, stroke=background, stroke_linejoin='round'))
labelG.add(dwg.text(pub[7], (x, 0.5 * labelHeight + xHeight * 0.8), font_size='80%'))
# arrows between story circles
pubs.sort(key=lambda pub : datetime.date(*pub[2])) # sort by publication date
lastNovel = last = None
for i, pub in enumerate(pubs):
if i > 0:
if pub[1]:
l = dwg.line(tuple(pubs[lastNovel][8]), tuple(pub[8]), stroke_width=2)
l.set_markers((None, None, arrow))
graphG.add(l)
if not (pub[1] and pubs[last][1]):
l = dwg.line(tuple(pubs[last][8]), tuple(pub[8]))
l.dasharray([5, 8])
l.set_markers((None, None, arrowShort2 if pub[1] else arrowShort))
graphG.add(l)
if pub[1]:
lastNovel = i
last = i
# story circles
for pub in pubs:
radius = novelRadius
scale = 1
textScale = 1.25
if not pub[1]:
scale = shortScale
radius *= scale
graphG.add(dwg.circle(tuple(pub[8]), radius))
label = dwg.text(pub[7], font_weight='bolder')
label.translate(pub[8][0], pub[8][1] + xHeight * scale * textScale)
label.scale(scale * textScale)
graphG.add(label)
genBrace(pub, braceWidth)
genPubArrow(pub)
dwg.save()
Licensing[edit]
- You are free:
- to share – to copy, distribute and transmit the work
- to remix – to adapt the work
- Under the following conditions:
- attribution – You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
- share alike – If you remix, transform, or build upon the material, you must distribute your contributions under the same or compatible license as the original.
File history
Click on a date/time to view the file as it appeared at that time.
Date/Time | Thumbnail | Dimensions | User | Comment | |
---|---|---|---|---|---|
current | 05:34, 14 February 2023 | 940 × 1,250 (63 KB) | Jaa101 (talk | contribs) | Narrowed. Lighter background. | |
04:59, 14 February 2023 | 1,000 × 1,200 (63 KB) | Jaa101 (talk | contribs) | Specify size. Correct arrowheads. | ||
03:59, 14 February 2023 | 512 × 512 (63 KB) | Jaa101 (talk | contribs) | librsvg bug work-arounds. Improvements | ||
08:53, 7 February 2023 | 512 × 512 (56 KB) | Jaa101 (talk | contribs) | Uploaded own work with UploadWizard |
You cannot overwrite this file.
File usage on Commons
There are no pages that use this file.
File usage on other wikis
The following other wikis use this file:
- Usage on en.wikipedia.org
Metadata
This file contains additional information such as Exif metadata which may have been added by the digital camera, scanner, or software program used to create or digitize it. If the file has been modified from its original state, some details such as the timestamp may not fully reflect those of the original file. The timestamp is only as accurate as the clock in the camera, and it may be completely wrong.
Width | 940 |
---|---|
Height | 1250 |