Jump to content

File:A Hornblower Chronology.svg

Page contents not supported in other languages.
This is a file from the Wikimedia Commons
From Wikipedia, the free encyclopedia

Original file (SVG file, nominally 940 × 1,250 pixels, file size: 63 KB)

Summary

Description
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

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

I, the copyright holder of this work, hereby publish it under the following license:
w:en:Creative Commons
attribution share alike
This file is licensed under the Creative Commons Attribution-Share Alike 4.0 International license.
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.

Captions

A chronology of the Hornblower stories by C. S. Forester, showing timelines of the publication and plots of each and of Hornblower's rank.

Items portrayed in this file

depicts

7 February 2023

image/svg+xml

fc5b82029a9efb352847125b27032aa43130592d

64,567 byte

1,250 pixel

940 pixel

File history

Click on a date/time to view the file as it appeared at that time.

Date/TimeThumbnailDimensionsUserComment
current05:34, 14 February 2023Thumbnail for version as of 05:34, 14 February 2023940 × 1,250 (63 KB)Jaa101Narrowed. Lighter background.
04:59, 14 February 2023Thumbnail for version as of 04:59, 14 February 20231,000 × 1,200 (63 KB)Jaa101Specify size. Correct arrowheads.
03:59, 14 February 2023Thumbnail for version as of 03:59, 14 February 2023512 × 512 (63 KB)Jaa101librsvg bug work-arounds. Improvements
08:53, 7 February 2023Thumbnail for version as of 08:53, 7 February 2023512 × 512 (56 KB)Jaa101Uploaded own work with UploadWizard

The following page uses this file:

Metadata