Skip to content

Commit 6de4c21

Browse files
authored
Multi-line, multi-column legend layout (#81, #49)
A new parameter, `plot-legend-layout`, allows placing legend entries into multiple rows or columns. This allows, for example, using a horizontal layout when the legend is placed at the top or bottom of the plot.
1 parent 85625af commit 6de4c21

File tree

15 files changed

+198
-63
lines changed

15 files changed

+198
-63
lines changed

plot-doc/plot/scribblings/params.scrbl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,21 @@ The font size (in drawing units), face, and family to prefer for the legend text
105105
(defparam plot-legend-box-alpha alpha (real-in 0 1) #:value 2/3))]{
106106
The placement of the legend and the opacity of its background.
107107
}
108+
@defparam[plot-legend-layout layout (list/c (or/c 'columns 'rows) positive-integer? (or/c 'equal-size 'compact)) #:value '(columns 1 equal-size)]{
109+
Defines the way in which individual entries are placed in the legend. This is a list of three
110+
elements:
111+
@itemlist[@item{the placement direction (@racket['columns] or @racket['rows])}
112+
@item{the number of columns or rows}
113+
@item{whether all the entries will have the same size (@racket['equal-size]),
114+
or the entries will only occupy the minimum size (@racket['compact])}]
115+
116+
For example, the value @racket['(columns 1 equal-size)] will place the legend entries vertically from
117+
top to bottom and all entries will have the same height. A value of @racket['(rows 2 'compact)] will
118+
place legend entries horizontally on two rows -- this type of layout is useful when the legend is
119+
placed at the top or bottom of the plot.
120+
121+
@history[#:added "7.9"]
122+
}
108123

109124
@defparam[plot-tick-size size (>=/c 0) #:value 10]{
110125
The length of tick lines, in drawing units.

plot-lib/plot/private/common/parameter-groups.rkt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
plot-line-width plot-tick-size
3434
plot-font-size plot-font-face plot-font-family
3535
plot-legend-font-size plot-legend-font-face plot-legend-font-family
36-
plot-legend-anchor plot-legend-box-alpha
36+
plot-legend-anchor plot-legend-box-alpha plot-legend-layout
3737
plot-axes? plot-tick-labels
3838
plot-decorations?
3939
plot-animating?
@@ -102,6 +102,7 @@
102102
(U False Font-Family)
103103
Legend-Anchor
104104
Nonnegative-Real
105+
Legend-Layout
105106
(List Boolean Boolean Boolean Boolean Boolean Boolean)
106107
(List Boolean Anchor Real (U Boolean 'auto) Anchor Real Boolean Anchor Real (U Boolean 'auto) Anchor Real)
107108
Boolean

plot-lib/plot/private/common/parameters.rkt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
(defparam plot-legend-font-face face (U String #f) #f)
7777
(defparam plot-legend-font-family family (U Font-Family #f) #f)
7878
(defparam plot-legend-anchor anchor Legend-Anchor 'top-left)
79+
(defparam plot-legend-layout layout Legend-Layout '(columns 1 equal-size))
7980
(defparam2 plot-legend-box-alpha alpha Real Nonnegative-Real 2/3 (unit-ivl 'plot-legend-box-alpha))
8081
(defparam plot-animating? Boolean #f)
8182

plot-lib/plot/private/common/plot-device.rkt

Lines changed: 126 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -610,74 +610,127 @@
610610
;; ===============================================================================================
611611
;; Legend
612612

613-
;; the folowing functions take a (Listof legend-entry) and a Rect as argument.
613+
;; the folowing functions take a (Listof legend-entry), a Rect and Anchor as argument.
614614
;; the understanding is that Rect will be the complete dc for a legend outside the plot-area
615615
;; and the plot-area otherwise
616616

617617
(: calculate-legend-parameters (-> (Listof legend-entry) Rect Anchor
618-
(Values Rect Exact-Rational Exact-Rational Exact-Rational
619-
Nonnegative-Exact-Rational Real Real
620-
Nonnegative-Exact-Rational Real)))
618+
(Values Rect (Listof Exact-Rational)
619+
Nonnegative-Exact-Rational (Listof Real) (Listof Real)
620+
Nonnegative-Exact-Rational (Listof Real)
621+
Boolean Nonnegative-Integer)))
621622
(define/private (calculate-legend-parameters legend-entries rect legend-anchor)
622623
(define n (length legend-entries))
623624
(define labels (map legend-entry-label legend-entries))
624625
(match-define (vector (ivl x-min x-max) (ivl y-min y-max)) rect)
625-
626-
(define-values (max-label-width max-label-height)
627-
(for/fold ([width : Exact-Rational 0]
628-
[max-height : Exact-Rational 0])
629-
([label (in-list labels)])
630-
(define-values (w h b a) (get-text-extent label))
631-
(values (max w width) (max h max-height))))
632-
633-
(define-values (horiz-gap min-label-height baseline _1)
634-
(get-text-extent " "))
626+
(cond
627+
[(and x-min x-max y-min y-max)
628+
(define-values (cols? rows cols compact?)
629+
(match (plot-legend-layout)
630+
[(list 'rows i compact)
631+
(values #f (min n i) (ceiling (/ n i)) (equal? compact 'compact))]
632+
[(list 'columns i compact)
633+
(values #t (ceiling (/ n i)) (min n i) (equal? compact 'compact))]))
634+
(define div (if cols? rows cols))
635+
636+
;; get max widths and heights per row/column
637+
(define-values (max-label-widths max-label-heights)
638+
(let-values ([(width height)
639+
(for/fold ([width : (HashTable Integer Exact-Rational) #hash()]
640+
[height : (HashTable Integer Exact-Rational) #hash()])
641+
([label (in-list labels)]
642+
[k (in-naturals)])
643+
(define-values (i j)
644+
(let-values ([(i j) (quotient/remainder k div)])
645+
(if cols? (values j i) (values i j))))
646+
(define-values (w h b a) (get-text-extent label))
647+
(values
648+
(hash-update width j (λ ([v : Exact-Rational]) (max w v)) (λ () 0))
649+
(hash-update height i (λ ([v : Exact-Rational]) (max h v)) (λ () 0))))])
650+
(define widths
651+
((inst map Exact-Rational (Pairof Integer Exact-Rational))
652+
cdr ((inst sort (Pairof Integer Exact-Rational))
653+
(hash->list width) < #:key car)))
654+
(define heights
655+
((inst map Exact-Rational (Pairof Integer Exact-Rational))
656+
cdr ((inst sort (Pairof Integer Exact-Rational))
657+
(hash->list height) < #:key car)))
658+
(cond
659+
[compact? (values widths heights)]
660+
[else
661+
(define max-width (apply max widths))
662+
(define max-heights (apply max heights))
663+
(values (map (λ (_) max-width) widths)
664+
(map (λ (_) max-heights) heights))])))
665+
666+
;; different gaps
667+
(define-values (horiz-gap min-label-height baseline _1)
668+
(get-text-extent " "))
635669

636-
(define top-gap baseline)
637-
(define bottom-gap (* 1/2 baseline))
638-
(define baseline-skip (+ max-label-height baseline))
639-
640-
(define labels-x-size (+ max-label-width horiz-gap))
641-
642-
(define draw-y-size (max 0 (- min-label-height baseline)))
643-
(define draw-x-size (* 4 draw-y-size))
644-
645-
(define legend-x-size (+ horiz-gap
646-
labels-x-size (* 2 horiz-gap)
647-
draw-x-size horiz-gap))
648-
(define legend-y-size (+ top-gap (* n baseline-skip) bottom-gap))
649-
650-
(define legend-x-min
651-
(cond
652-
[(and x-min x-max)
670+
(define top-gap baseline)
671+
(define bottom-gap (* 1/2 baseline))
672+
(define in-label-gap (* 3 horiz-gap))
673+
(define column-gap (* 3 in-label-gap))
674+
675+
;; size of legend line/square
676+
(define draw-y-size (max 0 (- min-label-height baseline)))
677+
(define draw-x-size (* 4 draw-y-size))
678+
679+
;; size of complete legend-entry
680+
(define x-skips (for/list : (Listof Exact-Rational)
681+
([w (in-list max-label-widths)])
682+
(+ w in-label-gap draw-x-size column-gap)))
683+
(define y-skips (for/list : (Listof Exact-Rational)
684+
([h (in-list max-label-heights)])
685+
(+ h baseline)))
686+
687+
;; size of complete legend
688+
(define legend-x-size (+ horiz-gap (- column-gap) horiz-gap
689+
(for/sum : Exact-Rational ([w (in-list x-skips)]) w)))
690+
(define legend-y-size (+ top-gap bottom-gap
691+
(for/sum : Exact-Rational ([h (in-list y-skips)]) h)))
692+
693+
;; top-left corner of legend
694+
(define legend-x-min
653695
(case legend-anchor
654696
[(top-left left bottom-left auto) x-min]
655697
[(top-right right bottom-right) (- x-max legend-x-size)]
656698
[(center bottom top) (- (* 1/2 (+ x-min x-max))
657-
(* 1/2 legend-x-size))])]
658-
[else
659-
(raise-argument-error 'draw-legend "rect-known?" 1 legend-entries rect)]))
699+
(* 1/2 legend-x-size))]))
660700

661-
(define legend-y-min
662-
(cond
663-
[(and y-min y-max)
701+
(define legend-y-min
664702
(case legend-anchor
665703
[(top-left top top-right auto) y-min]
666704
[(bottom-left bottom bottom-right) (- y-max legend-y-size)]
667705
[(center left right) (- (* 1/2 (+ y-min y-max))
668-
(* 1/2 legend-y-size))])]
669-
[else
670-
(raise-argument-error 'draw-legend "rect-known?" 1 legend-entries rect)]))
671-
672-
(define legend-rect (vector (ivl legend-x-min (+ legend-x-min legend-x-size))
673-
(ivl legend-y-min (+ legend-y-min legend-y-size))))
674-
675-
(define label-x-min (+ legend-x-min horiz-gap))
676-
(define draw-x-min (+ legend-x-min (* 2 horiz-gap) labels-x-size horiz-gap))
677-
678-
(values legend-rect top-gap baseline-skip max-label-height
679-
draw-x-size label-x-min draw-x-min
680-
draw-y-size legend-y-min))
706+
(* 1/2 legend-y-size))]))
707+
708+
(define legend-rect (vector (ivl legend-x-min (+ legend-x-min legend-x-size))
709+
(ivl legend-y-min (+ legend-y-min legend-y-size))))
710+
711+
;; per entry x/y left/top corners
712+
(define label-x-mins (for/fold ([mins : (Listof Real) (list (+ legend-x-min horiz-gap))]
713+
[prev : Real (+ legend-x-min horiz-gap)]
714+
#:result (reverse mins))
715+
([x (in-list x-skips)])
716+
(define nxt (+ prev x))
717+
(values (cons nxt mins) nxt)))
718+
(define label-y-mins (for/fold ([mins : (Listof Real) (list (+ legend-y-min top-gap))]
719+
[prev : Real (+ legend-y-min top-gap)]
720+
#:result (reverse mins))
721+
([y (in-list y-skips)])
722+
(define nxt (+ prev y))
723+
(values (cons nxt mins) nxt)))
724+
(define draw-x-mins (for/list : (Listof Real)
725+
([x (in-list label-x-mins)]
726+
[w (in-list max-label-widths)]) (+ x w in-label-gap)))
727+
728+
(values legend-rect max-label-heights
729+
draw-x-size label-x-mins draw-x-mins
730+
draw-y-size label-y-mins
731+
cols? div)]
732+
[else
733+
(raise-argument-error 'draw-legend "rect-known?" 1 legend-entries rect)]))
681734

682735
(define/public (calculate-legend-rect legend-entries rect legend-anchor)
683736
;; Change font for correct size calculation in calculate-legend-parameters
@@ -689,9 +742,10 @@
689742
(or (plot-legend-font-face) old-face)
690743
(or (plot-legend-font-family) old-family))
691744

692-
(define-values (legend-rect top-gap baseline-skip max-label-height
693-
draw-x-size label-x-min draw-x-min
694-
draw-y-size legend-y-min)
745+
(define-values (legend-rect max-label-heights
746+
draw-x-size label-x-mins draw-x-mins
747+
draw-y-size label-y-mins
748+
cols? div)
695749
(calculate-legend-parameters legend-entries rect legend-anchor))
696750

697751
;; Undo change font
@@ -701,7 +755,7 @@
701755

702756
(define/public (draw-legend legend-entries rect)
703757
(define legend-anchor (plot-legend-anchor))
704-
(when legend-anchor
758+
(when (not (eq? legend-anchor 'no-legend))
705759
(match-define (list (legend-entry #{labels : (Listof (U String pict))}
706760
#{draw-procs : (Listof Legend-Draw-Proc)})
707761
...)
@@ -716,9 +770,10 @@
716770
(or (plot-legend-font-face) old-face)
717771
(or (plot-legend-font-family) old-family))
718772

719-
(define-values (legend-rect top-gap baseline-skip max-label-height
720-
draw-x-size label-x-min draw-x-min
721-
draw-y-size legend-y-min)
773+
(define-values (legend-rect max-label-heights
774+
draw-x-size label-x-mins draw-x-mins
775+
draw-y-size label-y-mins
776+
cols? div)
722777
(calculate-legend-parameters legend-entries rect (legend-anchor->anchor legend-anchor)))
723778

724779
;; legend background
@@ -735,15 +790,24 @@
735790

736791
(set-alpha (plot-foreground-alpha))
737792
(set-clipping-rect legend-rect)
738-
(for ([label (in-list labels)] [draw-proc (in-list draw-procs)] [i (in-naturals)])
793+
(for ([label (in-list labels)] [draw-proc (in-list draw-procs)] [k (in-naturals)])
794+
(define-values (i j)
795+
(let-values ([(i j) (quotient/remainder k div)])
796+
(if cols? (values j i) (values i j))))
797+
739798
(define-values (_1 label-height _2 _3) (get-text-extent label))
740-
(define legend-entry-y-min (+ legend-y-min top-gap (* i baseline-skip)))
741-
(define label-y-min (+ legend-entry-y-min (* 1/2 (- max-label-height label-height))))
799+
(define label-x-min (list-ref label-x-mins j))
800+
(define legend-entry-y-min (list-ref label-y-mins i))
801+
(define max-label-height (list-ref max-label-heights i))
802+
(define label-y-min (+ legend-entry-y-min
803+
(* 1/2 (- max-label-height label-height))))
804+
742805
(if (pict? label)
743-
(draw-pict label (vector (ann label-x-min Real) (ann label-y-min Real)) 'top-left 0)
744-
(draw-text label (vector (ann label-x-min Real) (ann label-y-min Real)) 'top-left 0 0 #t))
806+
(draw-pict label (vector label-x-min label-y-min) 'top-left 0)
807+
(draw-text label (vector label-x-min label-y-min) 'top-left 0 0 #t))
745808

746809
(define draw-y-min (+ legend-entry-y-min (* 1/2 (- max-label-height draw-y-size))))
810+
(define draw-x-min (list-ref draw-x-mins j))
747811

748812
(define entry-pd (make-object plot-device% dc draw-x-min draw-y-min draw-x-size draw-y-size))
749813
(send entry-pd reset-drawing-params #f)

plot-lib/plot/private/common/types.rkt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@
117117
[(outside-left) 'left]
118118
[else 'auto])))
119119

120+
(deftype Legend-Layout (List (U 'rows 'columns) Positive-Integer (U 'equal-size 'compact)))
121+
120122
(define-type Plot-Device%
121123
(Class
122124
(init-field [dc (Instance DC<%>)]

plot-lib/plot/private/utils-and-no-gui.rkt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
plot-legend-font-family
9090
plot-legend-anchor
9191
plot-legend-box-alpha
92+
plot-legend-layout
9293
plot-decorations?
9394
plot-animating?
9495
plot-pen-color-map
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#lang racket
2+
(require rackunit
3+
plot pict
4+
racket/draw
5+
racket/runtime-path
6+
"../helpers.rkt")
7+
8+
;; Tests for: https://github.com/racket/plot/pull/68
9+
10+
(define rendertree
11+
(list (contour-intervals (λ (x y) (* x y)) 0 1 #:label "a")
12+
(function values 0 1 #:label "b" #:color 1)
13+
(function add1 0 1 #:label "a rather long one" #:color 2)
14+
(function sub1 0 1 #:label (standard-fish 20 40) #:color 4)))
15+
16+
(define (do-3row-equal output-fn)
17+
(parameterize ([plot-legend-layout '(rows 3 equal-size)])
18+
(output-fn rendertree #:legend-anchor 'outside-top)))
19+
20+
(define (do-3row-compact output-fn)
21+
(parameterize ([plot-legend-layout '(rows 3 compact)])
22+
(output-fn rendertree #:legend-anchor 'outside-top)))
23+
24+
(define (do-2col-equal output-fn)
25+
(parameterize ([plot-legend-layout '(columns 2 equal-size)])
26+
(output-fn rendertree #:legend-anchor 'outside-left)))
27+
28+
(define (do-2col-compact output-fn)
29+
(parameterize ([plot-legend-layout '(columns 2 compact)])
30+
(output-fn rendertree #:legend-anchor 'outside-left)))
31+
32+
(define-runtime-path pr81-data-1 "./test-data/pr81-1.dat")
33+
(define-runtime-path pr81-data-2 "./test-data/pr81-2.dat")
34+
(define-runtime-path pr81-data-3 "./test-data/pr81-3.dat")
35+
(define-runtime-path pr81-data-4 "./test-data/pr81-4.dat")
36+
37+
(define pr81-test-suite
38+
(test-suite
39+
"PR#81: horizontal layout for legend"
40+
(test-case "pr81-3row-equal"
41+
(check-draw-steps do-3row-equal pr81-data-1))
42+
(test-case "pr81-3row-compact"
43+
(check-draw-steps do-3row-compact pr81-data-2))
44+
(test-case "pr81-2col-equal"
45+
(check-draw-steps do-2col-equal pr81-data-3))
46+
(test-case "pr81-2col-compact"
47+
(check-draw-steps do-2col-compact pr81-data-4))))
48+
49+
(module+ test
50+
(require rackunit/text-ui)
51+
(run-tests pr81-test-suite))
51.2 KB
Binary file not shown.
53.1 KB
Loading
53.2 KB
Binary file not shown.

0 commit comments

Comments
 (0)